@mulmoclaude/core 0.5.1 → 0.6.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,192 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region src/remote-view/index.ts
3
+ /** Bump when the bootstrap/message contract changes shape; the bootstrap
4
+ * exposes it as `__MC_VIEW.protocol` so a parent can refuse a stale view. */
5
+ var REMOTE_VIEW_PROTOCOL = 1;
6
+ /** postMessage types between the sandboxed view and its parent page.
7
+ * `startChat` reuses the desktop custom-view message type on purpose — the
8
+ * desktop parent already understands it. */
9
+ var REMOTE_VIEW_MESSAGES = {
10
+ /** view → parent: request one page of records ({ requestId, offset, limit, fields }). */
11
+ getItems: "mc-remote-get-items",
12
+ /** parent → view: the reply ({ requestId, ok, page | error }). */
13
+ items: "mc-remote-items",
14
+ /** view → parent: open a new chat with a prefilled, NOT auto-sent draft. */
15
+ startChat: "mc-start-chat"
16
+ };
17
+ /** Pagination defaults — mirrored by the phase-2 record handlers
18
+ * (`server/remoteHost/handlers/collectionPage.ts` imports these) so a view
19
+ * page can never outgrow what the command channel itself serves. */
20
+ var DEFAULT_PAGE_LIMIT = 50;
21
+ var MAX_PAGE_LIMIT = 200;
22
+ /** Hard cap on the wrapped srcdoc: it travels to the phone INSIDE a Firestore
23
+ * command document (1 MiB total), so leave envelope headroom. */
24
+ var REMOTE_VIEW_MAX_BYTES = 9e5;
25
+ /** In-iframe `getItems` timeout — matches the remote client's `callHost`
26
+ * response timeout so the two layers give up together. */
27
+ var GET_ITEMS_TIMEOUT_MS = 3e4;
28
+ var SANDBOXED_VIEW_CDN_ALLOWLIST = [
29
+ "https://cdn.jsdelivr.net",
30
+ "https://unpkg.com",
31
+ "https://cdnjs.cloudflare.com",
32
+ "https://fonts.googleapis.com",
33
+ "https://fonts.gstatic.com",
34
+ "https://cdn.plot.ly"
35
+ ];
36
+ var toInt = (value) => {
37
+ const num = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
38
+ return Number.isFinite(num) ? Math.floor(num) : null;
39
+ };
40
+ /** Coerce a channel/postMessage offset (arrives as untyped JSON) to a non-negative int. */
41
+ var clampOffset = (value) => Math.max(0, toInt(value) ?? 0);
42
+ /** Coerce a channel/postMessage limit to [1, MAX_PAGE_LIMIT] (default 50). */
43
+ var clampLimit = (value) => {
44
+ const num = toInt(value);
45
+ if (num === null || num <= 0) return 50;
46
+ return Math.min(num, 200);
47
+ };
48
+ /** Coerce a `fields` projection list from untyped message JSON. */
49
+ var normalizeFields = (value) => {
50
+ if (!Array.isArray(value)) return void 0;
51
+ const cleaned = value.filter((entry) => typeof entry === "string").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
52
+ return cleaned.length > 0 ? cleaned : void 0;
53
+ };
54
+ /** Keep only `fields` (+ always the primary key) on each record. Parents apply
55
+ * this uniformly — the desktop preview via `pageFromItems`, the phone parent
56
+ * over the page it fetched through the channel — so a view sees the same
57
+ * projection everywhere. No-op without `fields`. */
58
+ function projectItems(items, fields, primaryKey) {
59
+ if (!fields || fields.length === 0) return items;
60
+ const keep = /* @__PURE__ */ new Set([primaryKey, ...fields]);
61
+ return items.map((item) => Object.fromEntries(Object.entries(item).filter(([key]) => keep.has(key))));
62
+ }
63
+ /** Answer a page request from an already-loaded record array (the desktop
64
+ * preview's data source): slice + project. Observable behavior matches the
65
+ * phone paging over the command channel. */
66
+ function pageFromItems(items, request, primaryKey) {
67
+ return {
68
+ items: projectItems(items.slice(request.offset, request.offset + request.limit), request.fields, primaryKey),
69
+ total: items.length,
70
+ offset: request.offset,
71
+ limit: request.limit
72
+ };
73
+ }
74
+ /**
75
+ * CSP for a remote (mobile) custom view. Stricter than the desktop custom-view
76
+ * policy: the view's data arrives over postMessage, so `connect-src` is
77
+ * `'none'` — no fetch / XHR / WebSocket / sendBeacon to ANY origin, which
78
+ * closes the bidirectional-exfiltration channel completely (there is no token
79
+ * to steal either). Script/style/font keep the curated CDN allowlist (the
80
+ * phone can reach the internet; only the host is unreachable), and
81
+ * `img-src`/`media-src` allow any `https:` host so record image/media URLs
82
+ * render — the same knowingly-accepted one-way GET-exfil tradeoff as the
83
+ * desktop policy (see buildCustomViewCsp in src/utils/html/previewCsp.ts).
84
+ */
85
+ function buildRemoteViewCsp(cdns = SANDBOXED_VIEW_CDN_ALLOWLIST) {
86
+ const cdnList = cdns.join(" ");
87
+ return [
88
+ "default-src 'none'",
89
+ `script-src 'unsafe-inline' ${cdnList}`,
90
+ `style-src 'unsafe-inline' ${cdnList}`,
91
+ `font-src ${cdnList}`,
92
+ `img-src ${cdnList} data: blob: https:`,
93
+ "media-src https: data: blob:",
94
+ "connect-src 'none'"
95
+ ].join("; ");
96
+ }
97
+ /** The in-iframe bootstrap installed before any of the view's own scripts.
98
+ * Owns the fiddly part of the contract — request/response correlation — so an
99
+ * LLM-authored view only ever awaits `__MC_VIEW.getItems(...)`:
100
+ *
101
+ * - `getItems({ offset, limit, fields })`: posts an `mc-remote-get-items`
102
+ * with a fresh `requestId`, resolves on the matching `mc-remote-items`
103
+ * reply (validated to come from `window.parent`), rejects on `ok: false`
104
+ * or after 30 s. targetOrigin `'*'` is safe: the request carries no secret
105
+ * and the parent is by construction the party supplying the data.
106
+ * - `startChat(prompt, role)`: same message type + semantics as the desktop
107
+ * bridge — the parent opens a new chat with `prompt` prefilled as an
108
+ * editable draft, never auto-sent.
109
+ * - `t(key, named)`: the same vue-i18n-compatible dict helper as the desktop
110
+ * bootstrap (named interpolation only), over the host-picked `dict`.
111
+ *
112
+ * Self-contained one-line string (no `<`, no `<\/script>`, `${}` only for the
113
+ * interpolated constants). */
114
+ function remoteViewBootstrap() {
115
+ return `(function(){var v=window.__MC_VIEW,seq=0,pend={};window.addEventListener('message',function(e){if(e.source!==window.parent)return;var d=e.data;if(!d||d.type!=='${REMOTE_VIEW_MESSAGES.items}')return;var p=pend[d.requestId];if(!p)return;delete pend[d.requestId];clearTimeout(p.timer);if(d.ok)p.resolve(d.page);else p.reject(new Error(typeof d.error==='string'?d.error:'load failed'));});v.getItems=function(opts){opts=opts&&typeof opts==='object'?opts:{};return new Promise(function(resolve,reject){var id='q'+(++seq);var timer=setTimeout(function(){delete pend[id];reject(new Error('getItems timed out'));},${GET_ITEMS_TIMEOUT_MS});pend[id]={resolve:resolve,reject:reject,timer:timer};window.parent.postMessage({type:'${REMOTE_VIEW_MESSAGES.getItems}',slug:v.slug,requestId:id,offset:opts.offset,limit:opts.limit,fields:opts.fields},'*');});};v.startChat=function(prompt,role){window.parent.postMessage({type:'${REMOTE_VIEW_MESSAGES.startChat}',slug:v.slug,prompt:String(prompt),role:typeof role==='string'?role:undefined},'*');};v.dict=v.dict||{};v.t=function(key,named){var s=v.dict[key];if(typeof s!=='string')return typeof key==='string'?key:String(key);if(!named||typeof named!=='object')return s;return s.replace(/\\{(\\w+)\\}/g,function(m,n){var x=named[n];return x==null?m:String(x);});};})();`;
116
+ }
117
+ /** Wrap a view's HTML into the sandboxed srcdoc: CSP meta + `__MC_VIEW` boot +
118
+ * bridge bootstrap injected at the start of `<head>` (before any view
119
+ * script). Runs HOST-side (`getRemoteView`) so the phone and the desktop
120
+ * preview receive the identical finished artifact. */
121
+ function buildRemoteViewSrcdoc(html, boot) {
122
+ const injection = `${`<meta http-equiv="Content-Security-Policy" content="${buildRemoteViewCsp()}">`}<script>window.__MC_VIEW=${JSON.stringify({
123
+ slug: boot.slug,
124
+ locale: boot.locale ?? "",
125
+ dict: boot.dict ?? {},
126
+ target: "mobile",
127
+ protocol: 1
128
+ }).replace(/</g, "\\u003c")};${remoteViewBootstrap()}<\/script>`;
129
+ if (/<head\b[^>]*>/i.test(html)) return html.replace(/(<head\b[^>]*>)/i, `$1${injection}`);
130
+ return `<!DOCTYPE html><html><head>${injection}</head><body>${html}</body></html>`;
131
+ }
132
+ async function answerGetItems(requestId, request, handlers, reply) {
133
+ try {
134
+ const page = await handlers.getPage(request);
135
+ reply({
136
+ type: REMOTE_VIEW_MESSAGES.items,
137
+ requestId,
138
+ ok: true,
139
+ page
140
+ });
141
+ } catch (err) {
142
+ reply({
143
+ type: REMOTE_VIEW_MESSAGES.items,
144
+ requestId,
145
+ ok: false,
146
+ error: err instanceof Error ? err.message : String(err)
147
+ });
148
+ }
149
+ }
150
+ /**
151
+ * Handle one message-event payload from a sandboxed remote view. DOM- and
152
+ * framework-free: the caller owns the `message` listener (and MUST verify
153
+ * `event.source === iframe.contentWindow` before calling), `reply` posts the
154
+ * response back into the iframe (targetOrigin `"*"` — the sandboxed document's
155
+ * origin is opaque, so nothing else can match). Returns true when the payload
156
+ * was a remote-view request for this slug (callers ignore everything else).
157
+ */
158
+ async function handleRemoteViewMessage(data, handlers, reply) {
159
+ if (typeof data !== "object" || data === null) return false;
160
+ const msg = data;
161
+ if (msg.slug !== handlers.slug) return false;
162
+ if (msg.type === REMOTE_VIEW_MESSAGES.startChat) {
163
+ const prompt = typeof msg.prompt === "string" ? msg.prompt.trim() : "";
164
+ if (prompt) handlers.onStartChat?.(prompt, typeof msg.role === "string" ? msg.role : void 0);
165
+ return true;
166
+ }
167
+ if (msg.type !== REMOTE_VIEW_MESSAGES.getItems || typeof msg.requestId !== "string") return false;
168
+ const request = {
169
+ offset: clampOffset(msg.offset),
170
+ limit: clampLimit(msg.limit),
171
+ fields: normalizeFields(msg.fields)
172
+ };
173
+ await answerGetItems(msg.requestId, request, handlers, reply);
174
+ return true;
175
+ }
176
+ //#endregion
177
+ exports.DEFAULT_PAGE_LIMIT = DEFAULT_PAGE_LIMIT;
178
+ exports.MAX_PAGE_LIMIT = MAX_PAGE_LIMIT;
179
+ exports.REMOTE_VIEW_MAX_BYTES = REMOTE_VIEW_MAX_BYTES;
180
+ exports.REMOTE_VIEW_MESSAGES = REMOTE_VIEW_MESSAGES;
181
+ exports.REMOTE_VIEW_PROTOCOL = REMOTE_VIEW_PROTOCOL;
182
+ exports.SANDBOXED_VIEW_CDN_ALLOWLIST = SANDBOXED_VIEW_CDN_ALLOWLIST;
183
+ exports.buildRemoteViewCsp = buildRemoteViewCsp;
184
+ exports.buildRemoteViewSrcdoc = buildRemoteViewSrcdoc;
185
+ exports.clampLimit = clampLimit;
186
+ exports.clampOffset = clampOffset;
187
+ exports.handleRemoteViewMessage = handleRemoteViewMessage;
188
+ exports.normalizeFields = normalizeFields;
189
+ exports.pageFromItems = pageFromItems;
190
+ exports.projectItems = projectItems;
191
+
192
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.cjs","names":[],"sources":["../../src/remote-view/index.ts"],"sourcesContent":["// The remote custom-view contract (phase 3 — plans/feat-remote-custom-view.md).\n//\n// Browser-safe single source of truth shared by the host server (which wraps\n// the view HTML into a sandboxed srcdoc), the desktop phone-frame preview, and\n// the mulmoserver mobile client (post-publish). A remote view runs on a phone\n// that can reach the internet but NOT the host's localhost, so — unlike the\n// desktop custom view (token + fetch to the view-data route) — its records\n// arrive over an async postMessage bridge owned by the parent page, and its\n// CSP locks `connect-src` to 'none' entirely.\n\n/** Bump when the bootstrap/message contract changes shape; the bootstrap\n * exposes it as `__MC_VIEW.protocol` so a parent can refuse a stale view. */\nexport const REMOTE_VIEW_PROTOCOL = 1;\n\n/** postMessage types between the sandboxed view and its parent page.\n * `startChat` reuses the desktop custom-view message type on purpose — the\n * desktop parent already understands it. */\nexport const REMOTE_VIEW_MESSAGES = {\n /** view → parent: request one page of records ({ requestId, offset, limit, fields }). */\n getItems: \"mc-remote-get-items\",\n /** parent → view: the reply ({ requestId, ok, page | error }). */\n items: \"mc-remote-items\",\n /** view → parent: open a new chat with a prefilled, NOT auto-sent draft. */\n startChat: \"mc-start-chat\",\n} as const;\n\n/** Pagination defaults — mirrored by the phase-2 record handlers\n * (`server/remoteHost/handlers/collectionPage.ts` imports these) so a view\n * page can never outgrow what the command channel itself serves. */\nexport const DEFAULT_PAGE_LIMIT = 50;\nexport const MAX_PAGE_LIMIT = 200;\n\n/** Hard cap on the wrapped srcdoc: it travels to the phone INSIDE a Firestore\n * command document (1 MiB total), so leave envelope headroom. */\nexport const REMOTE_VIEW_MAX_BYTES = 900_000;\n\n/** In-iframe `getItems` timeout — matches the remote client's `callHost`\n * response timeout so the two layers give up together. */\nconst GET_ITEMS_TIMEOUT_MS = 30_000;\n\n// CDN allowlist for sandboxed LLM-authored HTML (script/style/font loads).\n// Shared with the desktop preview + custom-view CSPs\n// (src/utils/html/previewCsp.ts re-exports it as its default) so the two\n// policies can't drift. Keep the list audited — every entry is a potential\n// supply-chain surface; the hosts here are reputable infrastructure that does\n// not expose per-request logs to third parties.\nexport const SANDBOXED_VIEW_CDN_ALLOWLIST: readonly string[] = [\n \"https://cdn.jsdelivr.net\",\n \"https://unpkg.com\",\n \"https://cdnjs.cloudflare.com\",\n \"https://fonts.googleapis.com\",\n \"https://fonts.gstatic.com\",\n // Plotly's first-party CDN — the LLM defaults to it for Plotly charts.\n \"https://cdn.plot.ly\",\n];\n\nconst toInt = (value: unknown): number | null => {\n const num = typeof value === \"number\" ? value : typeof value === \"string\" ? Number(value) : NaN;\n return Number.isFinite(num) ? Math.floor(num) : null;\n};\n\n/** Coerce a channel/postMessage offset (arrives as untyped JSON) to a non-negative int. */\nexport const clampOffset = (value: unknown): number => Math.max(0, toInt(value) ?? 0);\n\n/** Coerce a channel/postMessage limit to [1, MAX_PAGE_LIMIT] (default 50). */\nexport const clampLimit = (value: unknown): number => {\n const num = toInt(value);\n if (num === null || num <= 0) return DEFAULT_PAGE_LIMIT;\n return Math.min(num, MAX_PAGE_LIMIT);\n};\n\n/** Coerce a `fields` projection list from untyped message JSON. */\nexport const normalizeFields = (value: unknown): string[] | undefined => {\n if (!Array.isArray(value)) return undefined;\n const cleaned = value\n .filter((entry): entry is string => typeof entry === \"string\")\n .map((entry) => entry.trim())\n .filter((entry) => entry.length > 0);\n return cleaned.length > 0 ? cleaned : undefined;\n};\n\nexport type RemoteViewItem = Record<string, unknown>;\n\n/** One page of records, the resolved value of the view's `getItems()`. Same\n * shape as the phase-2 `getCollection` page so a parent can pass a channel\n * page straight through. */\nexport interface RemoteViewPage {\n items: RemoteViewItem[];\n total: number;\n offset: number;\n limit: number;\n}\n\n/** A normalized (clamped, fields-cleaned) page request handed to a parent's\n * `getPage` — `handleRemoteViewMessage` does the coercion so every parent\n * answers identical values. */\nexport interface RemoteViewPageRequest {\n offset: number;\n limit: number;\n fields?: string[];\n}\n\n/** Keep only `fields` (+ always the primary key) on each record. Parents apply\n * this uniformly — the desktop preview via `pageFromItems`, the phone parent\n * over the page it fetched through the channel — so a view sees the same\n * projection everywhere. No-op without `fields`. */\nexport function projectItems(items: RemoteViewItem[], fields: string[] | undefined, primaryKey: string): RemoteViewItem[] {\n if (!fields || fields.length === 0) return items;\n const keep = new Set([primaryKey, ...fields]);\n return items.map((item) => Object.fromEntries(Object.entries(item).filter(([key]) => keep.has(key))));\n}\n\n/** Answer a page request from an already-loaded record array (the desktop\n * preview's data source): slice + project. Observable behavior matches the\n * phone paging over the command channel. */\nexport function pageFromItems(items: RemoteViewItem[], request: RemoteViewPageRequest, primaryKey: string): RemoteViewPage {\n const pageItems = items.slice(request.offset, request.offset + request.limit);\n return { items: projectItems(pageItems, request.fields, primaryKey), total: items.length, offset: request.offset, limit: request.limit };\n}\n\n/**\n * CSP for a remote (mobile) custom view. Stricter than the desktop custom-view\n * policy: the view's data arrives over postMessage, so `connect-src` is\n * `'none'` — no fetch / XHR / WebSocket / sendBeacon to ANY origin, which\n * closes the bidirectional-exfiltration channel completely (there is no token\n * to steal either). Script/style/font keep the curated CDN allowlist (the\n * phone can reach the internet; only the host is unreachable), and\n * `img-src`/`media-src` allow any `https:` host so record image/media URLs\n * render — the same knowingly-accepted one-way GET-exfil tradeoff as the\n * desktop policy (see buildCustomViewCsp in src/utils/html/previewCsp.ts).\n */\nexport function buildRemoteViewCsp(cdns: readonly string[] = SANDBOXED_VIEW_CDN_ALLOWLIST): string {\n const cdnList = cdns.join(\" \");\n return [\n \"default-src 'none'\",\n `script-src 'unsafe-inline' ${cdnList}`,\n `style-src 'unsafe-inline' ${cdnList}`,\n `font-src ${cdnList}`,\n `img-src ${cdnList} data: blob: https:`,\n \"media-src https: data: blob:\",\n \"connect-src 'none'\",\n ].join(\"; \");\n}\n\n/** The in-iframe bootstrap installed before any of the view's own scripts.\n * Owns the fiddly part of the contract — request/response correlation — so an\n * LLM-authored view only ever awaits `__MC_VIEW.getItems(...)`:\n *\n * - `getItems({ offset, limit, fields })`: posts an `mc-remote-get-items`\n * with a fresh `requestId`, resolves on the matching `mc-remote-items`\n * reply (validated to come from `window.parent`), rejects on `ok: false`\n * or after 30 s. targetOrigin `'*'` is safe: the request carries no secret\n * and the parent is by construction the party supplying the data.\n * - `startChat(prompt, role)`: same message type + semantics as the desktop\n * bridge — the parent opens a new chat with `prompt` prefilled as an\n * editable draft, never auto-sent.\n * - `t(key, named)`: the same vue-i18n-compatible dict helper as the desktop\n * bootstrap (named interpolation only), over the host-picked `dict`.\n *\n * Self-contained one-line string (no `<`, no `</script>`, `${}` only for the\n * interpolated constants). */\nfunction remoteViewBootstrap(): string {\n return `(function(){var v=window.__MC_VIEW,seq=0,pend={};window.addEventListener('message',function(e){if(e.source!==window.parent)return;var d=e.data;if(!d||d.type!=='${REMOTE_VIEW_MESSAGES.items}')return;var p=pend[d.requestId];if(!p)return;delete pend[d.requestId];clearTimeout(p.timer);if(d.ok)p.resolve(d.page);else p.reject(new Error(typeof d.error==='string'?d.error:'load failed'));});v.getItems=function(opts){opts=opts&&typeof opts==='object'?opts:{};return new Promise(function(resolve,reject){var id='q'+(++seq);var timer=setTimeout(function(){delete pend[id];reject(new Error('getItems timed out'));},${GET_ITEMS_TIMEOUT_MS});pend[id]={resolve:resolve,reject:reject,timer:timer};window.parent.postMessage({type:'${REMOTE_VIEW_MESSAGES.getItems}',slug:v.slug,requestId:id,offset:opts.offset,limit:opts.limit,fields:opts.fields},'*');});};v.startChat=function(prompt,role){window.parent.postMessage({type:'${REMOTE_VIEW_MESSAGES.startChat}',slug:v.slug,prompt:String(prompt),role:typeof role==='string'?role:undefined},'*');};v.dict=v.dict||{};v.t=function(key,named){var s=v.dict[key];if(typeof s!=='string')return typeof key==='string'?key:String(key);if(!named||typeof named!=='object')return s;return s.replace(/\\\\{(\\\\w+)\\\\}/g,function(m,n){var x=named[n];return x==null?m:String(x);});};})();`;\n}\n\n/** What the host injects into `window.__MC_VIEW` — note what is ABSENT\n * compared to the desktop boot: no token, no dataUrl, no origin. */\nexport interface RemoteViewBoot {\n slug: string;\n /** Locale the dict was picked for; empty string when no translations. */\n locale?: string;\n /** Host-picked, locale-filtered flat string map (same contract as the\n * desktop custom-view dict). */\n dict?: Record<string, string>;\n}\n\n/** Wrap a view's HTML into the sandboxed srcdoc: CSP meta + `__MC_VIEW` boot +\n * bridge bootstrap injected at the start of `<head>` (before any view\n * script). Runs HOST-side (`getRemoteView`) so the phone and the desktop\n * preview receive the identical finished artifact. */\nexport function buildRemoteViewSrcdoc(html: string, boot: RemoteViewBoot): string {\n const cspMeta = `<meta http-equiv=\"Content-Security-Policy\" content=\"${buildRemoteViewCsp()}\">`;\n // `<`-escape the JSON so a hostile slug/dict string can't break out of the\n // <script> element (same escape as the desktop srcdoc builder).\n const json = JSON.stringify({\n slug: boot.slug,\n locale: boot.locale ?? \"\",\n dict: boot.dict ?? {},\n target: \"mobile\",\n protocol: REMOTE_VIEW_PROTOCOL,\n }).replace(/</g, \"\\\\u003c\");\n const injection = `${cspMeta}<script>window.__MC_VIEW=${json};${remoteViewBootstrap()}</script>`;\n if (/<head\\b[^>]*>/i.test(html)) {\n return html.replace(/(<head\\b[^>]*>)/i, `$1${injection}`);\n }\n return `<!DOCTYPE html><html><head>${injection}</head><body>${html}</body></html>`;\n}\n\n/** What a parent page provides to answer the sandboxed view. Deliberately\n * minimal — exactly the phone runtime's capabilities, nothing more, so the\n * desktop preview can never exceed what works on the phone. */\nexport interface RemoteViewBridgeHandlers {\n slug: string;\n /** Answer one normalized page request (already clamped + fields-cleaned). */\n getPage: (request: RemoteViewPageRequest) => Promise<RemoteViewPage> | RemoteViewPage;\n /** Relay a `startChat` draft; omit on a parent without a chat surface. */\n onStartChat?: (prompt: string, role?: string) => void;\n}\n\nasync function answerGetItems(requestId: string, request: RemoteViewPageRequest, handlers: RemoteViewBridgeHandlers, reply: RemoteViewReply): Promise<void> {\n try {\n const page = await handlers.getPage(request);\n reply({ type: REMOTE_VIEW_MESSAGES.items, requestId, ok: true, page });\n } catch (err) {\n reply({ type: REMOTE_VIEW_MESSAGES.items, requestId, ok: false, error: err instanceof Error ? err.message : String(err) });\n }\n}\n\ntype RemoteViewReply = (message: Record<string, unknown>) => void;\n\n/**\n * Handle one message-event payload from a sandboxed remote view. DOM- and\n * framework-free: the caller owns the `message` listener (and MUST verify\n * `event.source === iframe.contentWindow` before calling), `reply` posts the\n * response back into the iframe (targetOrigin `\"*\"` — the sandboxed document's\n * origin is opaque, so nothing else can match). Returns true when the payload\n * was a remote-view request for this slug (callers ignore everything else).\n */\nexport async function handleRemoteViewMessage(data: unknown, handlers: RemoteViewBridgeHandlers, reply: RemoteViewReply): Promise<boolean> {\n if (typeof data !== \"object\" || data === null) return false;\n const msg = data as {\n type?: unknown;\n slug?: unknown;\n requestId?: unknown;\n offset?: unknown;\n limit?: unknown;\n fields?: unknown;\n prompt?: unknown;\n role?: unknown;\n };\n if (msg.slug !== handlers.slug) return false;\n if (msg.type === REMOTE_VIEW_MESSAGES.startChat) {\n const prompt = typeof msg.prompt === \"string\" ? msg.prompt.trim() : \"\";\n if (prompt) handlers.onStartChat?.(prompt, typeof msg.role === \"string\" ? msg.role : undefined);\n return true;\n }\n if (msg.type !== REMOTE_VIEW_MESSAGES.getItems || typeof msg.requestId !== \"string\") return false;\n const request: RemoteViewPageRequest = { offset: clampOffset(msg.offset), limit: clampLimit(msg.limit), fields: normalizeFields(msg.fields) };\n await answerGetItems(msg.requestId, request, handlers, reply);\n return true;\n}\n"],"mappings":"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"}
@@ -0,0 +1,104 @@
1
+ /** Bump when the bootstrap/message contract changes shape; the bootstrap
2
+ * exposes it as `__MC_VIEW.protocol` so a parent can refuse a stale view. */
3
+ export declare const REMOTE_VIEW_PROTOCOL = 1;
4
+ /** postMessage types between the sandboxed view and its parent page.
5
+ * `startChat` reuses the desktop custom-view message type on purpose — the
6
+ * desktop parent already understands it. */
7
+ export declare const REMOTE_VIEW_MESSAGES: {
8
+ /** view → parent: request one page of records ({ requestId, offset, limit, fields }). */
9
+ readonly getItems: "mc-remote-get-items";
10
+ /** parent → view: the reply ({ requestId, ok, page | error }). */
11
+ readonly items: "mc-remote-items";
12
+ /** view → parent: open a new chat with a prefilled, NOT auto-sent draft. */
13
+ readonly startChat: "mc-start-chat";
14
+ };
15
+ /** Pagination defaults — mirrored by the phase-2 record handlers
16
+ * (`server/remoteHost/handlers/collectionPage.ts` imports these) so a view
17
+ * page can never outgrow what the command channel itself serves. */
18
+ export declare const DEFAULT_PAGE_LIMIT = 50;
19
+ export declare const MAX_PAGE_LIMIT = 200;
20
+ /** Hard cap on the wrapped srcdoc: it travels to the phone INSIDE a Firestore
21
+ * command document (1 MiB total), so leave envelope headroom. */
22
+ export declare const REMOTE_VIEW_MAX_BYTES = 900000;
23
+ export declare const SANDBOXED_VIEW_CDN_ALLOWLIST: readonly string[];
24
+ /** Coerce a channel/postMessage offset (arrives as untyped JSON) to a non-negative int. */
25
+ export declare const clampOffset: (value: unknown) => number;
26
+ /** Coerce a channel/postMessage limit to [1, MAX_PAGE_LIMIT] (default 50). */
27
+ export declare const clampLimit: (value: unknown) => number;
28
+ /** Coerce a `fields` projection list from untyped message JSON. */
29
+ export declare const normalizeFields: (value: unknown) => string[] | undefined;
30
+ export type RemoteViewItem = Record<string, unknown>;
31
+ /** One page of records, the resolved value of the view's `getItems()`. Same
32
+ * shape as the phase-2 `getCollection` page so a parent can pass a channel
33
+ * page straight through. */
34
+ export interface RemoteViewPage {
35
+ items: RemoteViewItem[];
36
+ total: number;
37
+ offset: number;
38
+ limit: number;
39
+ }
40
+ /** A normalized (clamped, fields-cleaned) page request handed to a parent's
41
+ * `getPage` — `handleRemoteViewMessage` does the coercion so every parent
42
+ * answers identical values. */
43
+ export interface RemoteViewPageRequest {
44
+ offset: number;
45
+ limit: number;
46
+ fields?: string[];
47
+ }
48
+ /** Keep only `fields` (+ always the primary key) on each record. Parents apply
49
+ * this uniformly — the desktop preview via `pageFromItems`, the phone parent
50
+ * over the page it fetched through the channel — so a view sees the same
51
+ * projection everywhere. No-op without `fields`. */
52
+ export declare function projectItems(items: RemoteViewItem[], fields: string[] | undefined, primaryKey: string): RemoteViewItem[];
53
+ /** Answer a page request from an already-loaded record array (the desktop
54
+ * preview's data source): slice + project. Observable behavior matches the
55
+ * phone paging over the command channel. */
56
+ export declare function pageFromItems(items: RemoteViewItem[], request: RemoteViewPageRequest, primaryKey: string): RemoteViewPage;
57
+ /**
58
+ * CSP for a remote (mobile) custom view. Stricter than the desktop custom-view
59
+ * policy: the view's data arrives over postMessage, so `connect-src` is
60
+ * `'none'` — no fetch / XHR / WebSocket / sendBeacon to ANY origin, which
61
+ * closes the bidirectional-exfiltration channel completely (there is no token
62
+ * to steal either). Script/style/font keep the curated CDN allowlist (the
63
+ * phone can reach the internet; only the host is unreachable), and
64
+ * `img-src`/`media-src` allow any `https:` host so record image/media URLs
65
+ * render — the same knowingly-accepted one-way GET-exfil tradeoff as the
66
+ * desktop policy (see buildCustomViewCsp in src/utils/html/previewCsp.ts).
67
+ */
68
+ export declare function buildRemoteViewCsp(cdns?: readonly string[]): string;
69
+ /** What the host injects into `window.__MC_VIEW` — note what is ABSENT
70
+ * compared to the desktop boot: no token, no dataUrl, no origin. */
71
+ export interface RemoteViewBoot {
72
+ slug: string;
73
+ /** Locale the dict was picked for; empty string when no translations. */
74
+ locale?: string;
75
+ /** Host-picked, locale-filtered flat string map (same contract as the
76
+ * desktop custom-view dict). */
77
+ dict?: Record<string, string>;
78
+ }
79
+ /** Wrap a view's HTML into the sandboxed srcdoc: CSP meta + `__MC_VIEW` boot +
80
+ * bridge bootstrap injected at the start of `<head>` (before any view
81
+ * script). Runs HOST-side (`getRemoteView`) so the phone and the desktop
82
+ * preview receive the identical finished artifact. */
83
+ export declare function buildRemoteViewSrcdoc(html: string, boot: RemoteViewBoot): string;
84
+ /** What a parent page provides to answer the sandboxed view. Deliberately
85
+ * minimal — exactly the phone runtime's capabilities, nothing more, so the
86
+ * desktop preview can never exceed what works on the phone. */
87
+ export interface RemoteViewBridgeHandlers {
88
+ slug: string;
89
+ /** Answer one normalized page request (already clamped + fields-cleaned). */
90
+ getPage: (request: RemoteViewPageRequest) => Promise<RemoteViewPage> | RemoteViewPage;
91
+ /** Relay a `startChat` draft; omit on a parent without a chat surface. */
92
+ onStartChat?: (prompt: string, role?: string) => void;
93
+ }
94
+ type RemoteViewReply = (message: Record<string, unknown>) => void;
95
+ /**
96
+ * Handle one message-event payload from a sandboxed remote view. DOM- and
97
+ * framework-free: the caller owns the `message` listener (and MUST verify
98
+ * `event.source === iframe.contentWindow` before calling), `reply` posts the
99
+ * response back into the iframe (targetOrigin `"*"` — the sandboxed document's
100
+ * origin is opaque, so nothing else can match). Returns true when the payload
101
+ * was a remote-view request for this slug (callers ignore everything else).
102
+ */
103
+ export declare function handleRemoteViewMessage(data: unknown, handlers: RemoteViewBridgeHandlers, reply: RemoteViewReply): Promise<boolean>;
104
+ export {};
@@ -0,0 +1,178 @@
1
+ //#region src/remote-view/index.ts
2
+ /** Bump when the bootstrap/message contract changes shape; the bootstrap
3
+ * exposes it as `__MC_VIEW.protocol` so a parent can refuse a stale view. */
4
+ var REMOTE_VIEW_PROTOCOL = 1;
5
+ /** postMessage types between the sandboxed view and its parent page.
6
+ * `startChat` reuses the desktop custom-view message type on purpose — the
7
+ * desktop parent already understands it. */
8
+ var REMOTE_VIEW_MESSAGES = {
9
+ /** view → parent: request one page of records ({ requestId, offset, limit, fields }). */
10
+ getItems: "mc-remote-get-items",
11
+ /** parent → view: the reply ({ requestId, ok, page | error }). */
12
+ items: "mc-remote-items",
13
+ /** view → parent: open a new chat with a prefilled, NOT auto-sent draft. */
14
+ startChat: "mc-start-chat"
15
+ };
16
+ /** Pagination defaults — mirrored by the phase-2 record handlers
17
+ * (`server/remoteHost/handlers/collectionPage.ts` imports these) so a view
18
+ * page can never outgrow what the command channel itself serves. */
19
+ var DEFAULT_PAGE_LIMIT = 50;
20
+ var MAX_PAGE_LIMIT = 200;
21
+ /** Hard cap on the wrapped srcdoc: it travels to the phone INSIDE a Firestore
22
+ * command document (1 MiB total), so leave envelope headroom. */
23
+ var REMOTE_VIEW_MAX_BYTES = 9e5;
24
+ /** In-iframe `getItems` timeout — matches the remote client's `callHost`
25
+ * response timeout so the two layers give up together. */
26
+ var GET_ITEMS_TIMEOUT_MS = 3e4;
27
+ var SANDBOXED_VIEW_CDN_ALLOWLIST = [
28
+ "https://cdn.jsdelivr.net",
29
+ "https://unpkg.com",
30
+ "https://cdnjs.cloudflare.com",
31
+ "https://fonts.googleapis.com",
32
+ "https://fonts.gstatic.com",
33
+ "https://cdn.plot.ly"
34
+ ];
35
+ var toInt = (value) => {
36
+ const num = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
37
+ return Number.isFinite(num) ? Math.floor(num) : null;
38
+ };
39
+ /** Coerce a channel/postMessage offset (arrives as untyped JSON) to a non-negative int. */
40
+ var clampOffset = (value) => Math.max(0, toInt(value) ?? 0);
41
+ /** Coerce a channel/postMessage limit to [1, MAX_PAGE_LIMIT] (default 50). */
42
+ var clampLimit = (value) => {
43
+ const num = toInt(value);
44
+ if (num === null || num <= 0) return 50;
45
+ return Math.min(num, 200);
46
+ };
47
+ /** Coerce a `fields` projection list from untyped message JSON. */
48
+ var normalizeFields = (value) => {
49
+ if (!Array.isArray(value)) return void 0;
50
+ const cleaned = value.filter((entry) => typeof entry === "string").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
51
+ return cleaned.length > 0 ? cleaned : void 0;
52
+ };
53
+ /** Keep only `fields` (+ always the primary key) on each record. Parents apply
54
+ * this uniformly — the desktop preview via `pageFromItems`, the phone parent
55
+ * over the page it fetched through the channel — so a view sees the same
56
+ * projection everywhere. No-op without `fields`. */
57
+ function projectItems(items, fields, primaryKey) {
58
+ if (!fields || fields.length === 0) return items;
59
+ const keep = /* @__PURE__ */ new Set([primaryKey, ...fields]);
60
+ return items.map((item) => Object.fromEntries(Object.entries(item).filter(([key]) => keep.has(key))));
61
+ }
62
+ /** Answer a page request from an already-loaded record array (the desktop
63
+ * preview's data source): slice + project. Observable behavior matches the
64
+ * phone paging over the command channel. */
65
+ function pageFromItems(items, request, primaryKey) {
66
+ return {
67
+ items: projectItems(items.slice(request.offset, request.offset + request.limit), request.fields, primaryKey),
68
+ total: items.length,
69
+ offset: request.offset,
70
+ limit: request.limit
71
+ };
72
+ }
73
+ /**
74
+ * CSP for a remote (mobile) custom view. Stricter than the desktop custom-view
75
+ * policy: the view's data arrives over postMessage, so `connect-src` is
76
+ * `'none'` — no fetch / XHR / WebSocket / sendBeacon to ANY origin, which
77
+ * closes the bidirectional-exfiltration channel completely (there is no token
78
+ * to steal either). Script/style/font keep the curated CDN allowlist (the
79
+ * phone can reach the internet; only the host is unreachable), and
80
+ * `img-src`/`media-src` allow any `https:` host so record image/media URLs
81
+ * render — the same knowingly-accepted one-way GET-exfil tradeoff as the
82
+ * desktop policy (see buildCustomViewCsp in src/utils/html/previewCsp.ts).
83
+ */
84
+ function buildRemoteViewCsp(cdns = SANDBOXED_VIEW_CDN_ALLOWLIST) {
85
+ const cdnList = cdns.join(" ");
86
+ return [
87
+ "default-src 'none'",
88
+ `script-src 'unsafe-inline' ${cdnList}`,
89
+ `style-src 'unsafe-inline' ${cdnList}`,
90
+ `font-src ${cdnList}`,
91
+ `img-src ${cdnList} data: blob: https:`,
92
+ "media-src https: data: blob:",
93
+ "connect-src 'none'"
94
+ ].join("; ");
95
+ }
96
+ /** The in-iframe bootstrap installed before any of the view's own scripts.
97
+ * Owns the fiddly part of the contract — request/response correlation — so an
98
+ * LLM-authored view only ever awaits `__MC_VIEW.getItems(...)`:
99
+ *
100
+ * - `getItems({ offset, limit, fields })`: posts an `mc-remote-get-items`
101
+ * with a fresh `requestId`, resolves on the matching `mc-remote-items`
102
+ * reply (validated to come from `window.parent`), rejects on `ok: false`
103
+ * or after 30 s. targetOrigin `'*'` is safe: the request carries no secret
104
+ * and the parent is by construction the party supplying the data.
105
+ * - `startChat(prompt, role)`: same message type + semantics as the desktop
106
+ * bridge — the parent opens a new chat with `prompt` prefilled as an
107
+ * editable draft, never auto-sent.
108
+ * - `t(key, named)`: the same vue-i18n-compatible dict helper as the desktop
109
+ * bootstrap (named interpolation only), over the host-picked `dict`.
110
+ *
111
+ * Self-contained one-line string (no `<`, no `<\/script>`, `${}` only for the
112
+ * interpolated constants). */
113
+ function remoteViewBootstrap() {
114
+ return `(function(){var v=window.__MC_VIEW,seq=0,pend={};window.addEventListener('message',function(e){if(e.source!==window.parent)return;var d=e.data;if(!d||d.type!=='${REMOTE_VIEW_MESSAGES.items}')return;var p=pend[d.requestId];if(!p)return;delete pend[d.requestId];clearTimeout(p.timer);if(d.ok)p.resolve(d.page);else p.reject(new Error(typeof d.error==='string'?d.error:'load failed'));});v.getItems=function(opts){opts=opts&&typeof opts==='object'?opts:{};return new Promise(function(resolve,reject){var id='q'+(++seq);var timer=setTimeout(function(){delete pend[id];reject(new Error('getItems timed out'));},${GET_ITEMS_TIMEOUT_MS});pend[id]={resolve:resolve,reject:reject,timer:timer};window.parent.postMessage({type:'${REMOTE_VIEW_MESSAGES.getItems}',slug:v.slug,requestId:id,offset:opts.offset,limit:opts.limit,fields:opts.fields},'*');});};v.startChat=function(prompt,role){window.parent.postMessage({type:'${REMOTE_VIEW_MESSAGES.startChat}',slug:v.slug,prompt:String(prompt),role:typeof role==='string'?role:undefined},'*');};v.dict=v.dict||{};v.t=function(key,named){var s=v.dict[key];if(typeof s!=='string')return typeof key==='string'?key:String(key);if(!named||typeof named!=='object')return s;return s.replace(/\\{(\\w+)\\}/g,function(m,n){var x=named[n];return x==null?m:String(x);});};})();`;
115
+ }
116
+ /** Wrap a view's HTML into the sandboxed srcdoc: CSP meta + `__MC_VIEW` boot +
117
+ * bridge bootstrap injected at the start of `<head>` (before any view
118
+ * script). Runs HOST-side (`getRemoteView`) so the phone and the desktop
119
+ * preview receive the identical finished artifact. */
120
+ function buildRemoteViewSrcdoc(html, boot) {
121
+ const injection = `${`<meta http-equiv="Content-Security-Policy" content="${buildRemoteViewCsp()}">`}<script>window.__MC_VIEW=${JSON.stringify({
122
+ slug: boot.slug,
123
+ locale: boot.locale ?? "",
124
+ dict: boot.dict ?? {},
125
+ target: "mobile",
126
+ protocol: 1
127
+ }).replace(/</g, "\\u003c")};${remoteViewBootstrap()}<\/script>`;
128
+ if (/<head\b[^>]*>/i.test(html)) return html.replace(/(<head\b[^>]*>)/i, `$1${injection}`);
129
+ return `<!DOCTYPE html><html><head>${injection}</head><body>${html}</body></html>`;
130
+ }
131
+ async function answerGetItems(requestId, request, handlers, reply) {
132
+ try {
133
+ const page = await handlers.getPage(request);
134
+ reply({
135
+ type: REMOTE_VIEW_MESSAGES.items,
136
+ requestId,
137
+ ok: true,
138
+ page
139
+ });
140
+ } catch (err) {
141
+ reply({
142
+ type: REMOTE_VIEW_MESSAGES.items,
143
+ requestId,
144
+ ok: false,
145
+ error: err instanceof Error ? err.message : String(err)
146
+ });
147
+ }
148
+ }
149
+ /**
150
+ * Handle one message-event payload from a sandboxed remote view. DOM- and
151
+ * framework-free: the caller owns the `message` listener (and MUST verify
152
+ * `event.source === iframe.contentWindow` before calling), `reply` posts the
153
+ * response back into the iframe (targetOrigin `"*"` — the sandboxed document's
154
+ * origin is opaque, so nothing else can match). Returns true when the payload
155
+ * was a remote-view request for this slug (callers ignore everything else).
156
+ */
157
+ async function handleRemoteViewMessage(data, handlers, reply) {
158
+ if (typeof data !== "object" || data === null) return false;
159
+ const msg = data;
160
+ if (msg.slug !== handlers.slug) return false;
161
+ if (msg.type === REMOTE_VIEW_MESSAGES.startChat) {
162
+ const prompt = typeof msg.prompt === "string" ? msg.prompt.trim() : "";
163
+ if (prompt) handlers.onStartChat?.(prompt, typeof msg.role === "string" ? msg.role : void 0);
164
+ return true;
165
+ }
166
+ if (msg.type !== REMOTE_VIEW_MESSAGES.getItems || typeof msg.requestId !== "string") return false;
167
+ const request = {
168
+ offset: clampOffset(msg.offset),
169
+ limit: clampLimit(msg.limit),
170
+ fields: normalizeFields(msg.fields)
171
+ };
172
+ await answerGetItems(msg.requestId, request, handlers, reply);
173
+ return true;
174
+ }
175
+ //#endregion
176
+ export { DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT, REMOTE_VIEW_MAX_BYTES, REMOTE_VIEW_MESSAGES, REMOTE_VIEW_PROTOCOL, SANDBOXED_VIEW_CDN_ALLOWLIST, buildRemoteViewCsp, buildRemoteViewSrcdoc, clampLimit, clampOffset, handleRemoteViewMessage, normalizeFields, pageFromItems, projectItems };
177
+
178
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/remote-view/index.ts"],"sourcesContent":["// The remote custom-view contract (phase 3 — plans/feat-remote-custom-view.md).\n//\n// Browser-safe single source of truth shared by the host server (which wraps\n// the view HTML into a sandboxed srcdoc), the desktop phone-frame preview, and\n// the mulmoserver mobile client (post-publish). A remote view runs on a phone\n// that can reach the internet but NOT the host's localhost, so — unlike the\n// desktop custom view (token + fetch to the view-data route) — its records\n// arrive over an async postMessage bridge owned by the parent page, and its\n// CSP locks `connect-src` to 'none' entirely.\n\n/** Bump when the bootstrap/message contract changes shape; the bootstrap\n * exposes it as `__MC_VIEW.protocol` so a parent can refuse a stale view. */\nexport const REMOTE_VIEW_PROTOCOL = 1;\n\n/** postMessage types between the sandboxed view and its parent page.\n * `startChat` reuses the desktop custom-view message type on purpose — the\n * desktop parent already understands it. */\nexport const REMOTE_VIEW_MESSAGES = {\n /** view → parent: request one page of records ({ requestId, offset, limit, fields }). */\n getItems: \"mc-remote-get-items\",\n /** parent → view: the reply ({ requestId, ok, page | error }). */\n items: \"mc-remote-items\",\n /** view → parent: open a new chat with a prefilled, NOT auto-sent draft. */\n startChat: \"mc-start-chat\",\n} as const;\n\n/** Pagination defaults — mirrored by the phase-2 record handlers\n * (`server/remoteHost/handlers/collectionPage.ts` imports these) so a view\n * page can never outgrow what the command channel itself serves. */\nexport const DEFAULT_PAGE_LIMIT = 50;\nexport const MAX_PAGE_LIMIT = 200;\n\n/** Hard cap on the wrapped srcdoc: it travels to the phone INSIDE a Firestore\n * command document (1 MiB total), so leave envelope headroom. */\nexport const REMOTE_VIEW_MAX_BYTES = 900_000;\n\n/** In-iframe `getItems` timeout — matches the remote client's `callHost`\n * response timeout so the two layers give up together. */\nconst GET_ITEMS_TIMEOUT_MS = 30_000;\n\n// CDN allowlist for sandboxed LLM-authored HTML (script/style/font loads).\n// Shared with the desktop preview + custom-view CSPs\n// (src/utils/html/previewCsp.ts re-exports it as its default) so the two\n// policies can't drift. Keep the list audited — every entry is a potential\n// supply-chain surface; the hosts here are reputable infrastructure that does\n// not expose per-request logs to third parties.\nexport const SANDBOXED_VIEW_CDN_ALLOWLIST: readonly string[] = [\n \"https://cdn.jsdelivr.net\",\n \"https://unpkg.com\",\n \"https://cdnjs.cloudflare.com\",\n \"https://fonts.googleapis.com\",\n \"https://fonts.gstatic.com\",\n // Plotly's first-party CDN — the LLM defaults to it for Plotly charts.\n \"https://cdn.plot.ly\",\n];\n\nconst toInt = (value: unknown): number | null => {\n const num = typeof value === \"number\" ? value : typeof value === \"string\" ? Number(value) : NaN;\n return Number.isFinite(num) ? Math.floor(num) : null;\n};\n\n/** Coerce a channel/postMessage offset (arrives as untyped JSON) to a non-negative int. */\nexport const clampOffset = (value: unknown): number => Math.max(0, toInt(value) ?? 0);\n\n/** Coerce a channel/postMessage limit to [1, MAX_PAGE_LIMIT] (default 50). */\nexport const clampLimit = (value: unknown): number => {\n const num = toInt(value);\n if (num === null || num <= 0) return DEFAULT_PAGE_LIMIT;\n return Math.min(num, MAX_PAGE_LIMIT);\n};\n\n/** Coerce a `fields` projection list from untyped message JSON. */\nexport const normalizeFields = (value: unknown): string[] | undefined => {\n if (!Array.isArray(value)) return undefined;\n const cleaned = value\n .filter((entry): entry is string => typeof entry === \"string\")\n .map((entry) => entry.trim())\n .filter((entry) => entry.length > 0);\n return cleaned.length > 0 ? cleaned : undefined;\n};\n\nexport type RemoteViewItem = Record<string, unknown>;\n\n/** One page of records, the resolved value of the view's `getItems()`. Same\n * shape as the phase-2 `getCollection` page so a parent can pass a channel\n * page straight through. */\nexport interface RemoteViewPage {\n items: RemoteViewItem[];\n total: number;\n offset: number;\n limit: number;\n}\n\n/** A normalized (clamped, fields-cleaned) page request handed to a parent's\n * `getPage` — `handleRemoteViewMessage` does the coercion so every parent\n * answers identical values. */\nexport interface RemoteViewPageRequest {\n offset: number;\n limit: number;\n fields?: string[];\n}\n\n/** Keep only `fields` (+ always the primary key) on each record. Parents apply\n * this uniformly — the desktop preview via `pageFromItems`, the phone parent\n * over the page it fetched through the channel — so a view sees the same\n * projection everywhere. No-op without `fields`. */\nexport function projectItems(items: RemoteViewItem[], fields: string[] | undefined, primaryKey: string): RemoteViewItem[] {\n if (!fields || fields.length === 0) return items;\n const keep = new Set([primaryKey, ...fields]);\n return items.map((item) => Object.fromEntries(Object.entries(item).filter(([key]) => keep.has(key))));\n}\n\n/** Answer a page request from an already-loaded record array (the desktop\n * preview's data source): slice + project. Observable behavior matches the\n * phone paging over the command channel. */\nexport function pageFromItems(items: RemoteViewItem[], request: RemoteViewPageRequest, primaryKey: string): RemoteViewPage {\n const pageItems = items.slice(request.offset, request.offset + request.limit);\n return { items: projectItems(pageItems, request.fields, primaryKey), total: items.length, offset: request.offset, limit: request.limit };\n}\n\n/**\n * CSP for a remote (mobile) custom view. Stricter than the desktop custom-view\n * policy: the view's data arrives over postMessage, so `connect-src` is\n * `'none'` — no fetch / XHR / WebSocket / sendBeacon to ANY origin, which\n * closes the bidirectional-exfiltration channel completely (there is no token\n * to steal either). Script/style/font keep the curated CDN allowlist (the\n * phone can reach the internet; only the host is unreachable), and\n * `img-src`/`media-src` allow any `https:` host so record image/media URLs\n * render — the same knowingly-accepted one-way GET-exfil tradeoff as the\n * desktop policy (see buildCustomViewCsp in src/utils/html/previewCsp.ts).\n */\nexport function buildRemoteViewCsp(cdns: readonly string[] = SANDBOXED_VIEW_CDN_ALLOWLIST): string {\n const cdnList = cdns.join(\" \");\n return [\n \"default-src 'none'\",\n `script-src 'unsafe-inline' ${cdnList}`,\n `style-src 'unsafe-inline' ${cdnList}`,\n `font-src ${cdnList}`,\n `img-src ${cdnList} data: blob: https:`,\n \"media-src https: data: blob:\",\n \"connect-src 'none'\",\n ].join(\"; \");\n}\n\n/** The in-iframe bootstrap installed before any of the view's own scripts.\n * Owns the fiddly part of the contract — request/response correlation — so an\n * LLM-authored view only ever awaits `__MC_VIEW.getItems(...)`:\n *\n * - `getItems({ offset, limit, fields })`: posts an `mc-remote-get-items`\n * with a fresh `requestId`, resolves on the matching `mc-remote-items`\n * reply (validated to come from `window.parent`), rejects on `ok: false`\n * or after 30 s. targetOrigin `'*'` is safe: the request carries no secret\n * and the parent is by construction the party supplying the data.\n * - `startChat(prompt, role)`: same message type + semantics as the desktop\n * bridge — the parent opens a new chat with `prompt` prefilled as an\n * editable draft, never auto-sent.\n * - `t(key, named)`: the same vue-i18n-compatible dict helper as the desktop\n * bootstrap (named interpolation only), over the host-picked `dict`.\n *\n * Self-contained one-line string (no `<`, no `</script>`, `${}` only for the\n * interpolated constants). */\nfunction remoteViewBootstrap(): string {\n return `(function(){var v=window.__MC_VIEW,seq=0,pend={};window.addEventListener('message',function(e){if(e.source!==window.parent)return;var d=e.data;if(!d||d.type!=='${REMOTE_VIEW_MESSAGES.items}')return;var p=pend[d.requestId];if(!p)return;delete pend[d.requestId];clearTimeout(p.timer);if(d.ok)p.resolve(d.page);else p.reject(new Error(typeof d.error==='string'?d.error:'load failed'));});v.getItems=function(opts){opts=opts&&typeof opts==='object'?opts:{};return new Promise(function(resolve,reject){var id='q'+(++seq);var timer=setTimeout(function(){delete pend[id];reject(new Error('getItems timed out'));},${GET_ITEMS_TIMEOUT_MS});pend[id]={resolve:resolve,reject:reject,timer:timer};window.parent.postMessage({type:'${REMOTE_VIEW_MESSAGES.getItems}',slug:v.slug,requestId:id,offset:opts.offset,limit:opts.limit,fields:opts.fields},'*');});};v.startChat=function(prompt,role){window.parent.postMessage({type:'${REMOTE_VIEW_MESSAGES.startChat}',slug:v.slug,prompt:String(prompt),role:typeof role==='string'?role:undefined},'*');};v.dict=v.dict||{};v.t=function(key,named){var s=v.dict[key];if(typeof s!=='string')return typeof key==='string'?key:String(key);if(!named||typeof named!=='object')return s;return s.replace(/\\\\{(\\\\w+)\\\\}/g,function(m,n){var x=named[n];return x==null?m:String(x);});};})();`;\n}\n\n/** What the host injects into `window.__MC_VIEW` — note what is ABSENT\n * compared to the desktop boot: no token, no dataUrl, no origin. */\nexport interface RemoteViewBoot {\n slug: string;\n /** Locale the dict was picked for; empty string when no translations. */\n locale?: string;\n /** Host-picked, locale-filtered flat string map (same contract as the\n * desktop custom-view dict). */\n dict?: Record<string, string>;\n}\n\n/** Wrap a view's HTML into the sandboxed srcdoc: CSP meta + `__MC_VIEW` boot +\n * bridge bootstrap injected at the start of `<head>` (before any view\n * script). Runs HOST-side (`getRemoteView`) so the phone and the desktop\n * preview receive the identical finished artifact. */\nexport function buildRemoteViewSrcdoc(html: string, boot: RemoteViewBoot): string {\n const cspMeta = `<meta http-equiv=\"Content-Security-Policy\" content=\"${buildRemoteViewCsp()}\">`;\n // `<`-escape the JSON so a hostile slug/dict string can't break out of the\n // <script> element (same escape as the desktop srcdoc builder).\n const json = JSON.stringify({\n slug: boot.slug,\n locale: boot.locale ?? \"\",\n dict: boot.dict ?? {},\n target: \"mobile\",\n protocol: REMOTE_VIEW_PROTOCOL,\n }).replace(/</g, \"\\\\u003c\");\n const injection = `${cspMeta}<script>window.__MC_VIEW=${json};${remoteViewBootstrap()}</script>`;\n if (/<head\\b[^>]*>/i.test(html)) {\n return html.replace(/(<head\\b[^>]*>)/i, `$1${injection}`);\n }\n return `<!DOCTYPE html><html><head>${injection}</head><body>${html}</body></html>`;\n}\n\n/** What a parent page provides to answer the sandboxed view. Deliberately\n * minimal — exactly the phone runtime's capabilities, nothing more, so the\n * desktop preview can never exceed what works on the phone. */\nexport interface RemoteViewBridgeHandlers {\n slug: string;\n /** Answer one normalized page request (already clamped + fields-cleaned). */\n getPage: (request: RemoteViewPageRequest) => Promise<RemoteViewPage> | RemoteViewPage;\n /** Relay a `startChat` draft; omit on a parent without a chat surface. */\n onStartChat?: (prompt: string, role?: string) => void;\n}\n\nasync function answerGetItems(requestId: string, request: RemoteViewPageRequest, handlers: RemoteViewBridgeHandlers, reply: RemoteViewReply): Promise<void> {\n try {\n const page = await handlers.getPage(request);\n reply({ type: REMOTE_VIEW_MESSAGES.items, requestId, ok: true, page });\n } catch (err) {\n reply({ type: REMOTE_VIEW_MESSAGES.items, requestId, ok: false, error: err instanceof Error ? err.message : String(err) });\n }\n}\n\ntype RemoteViewReply = (message: Record<string, unknown>) => void;\n\n/**\n * Handle one message-event payload from a sandboxed remote view. DOM- and\n * framework-free: the caller owns the `message` listener (and MUST verify\n * `event.source === iframe.contentWindow` before calling), `reply` posts the\n * response back into the iframe (targetOrigin `\"*\"` — the sandboxed document's\n * origin is opaque, so nothing else can match). Returns true when the payload\n * was a remote-view request for this slug (callers ignore everything else).\n */\nexport async function handleRemoteViewMessage(data: unknown, handlers: RemoteViewBridgeHandlers, reply: RemoteViewReply): Promise<boolean> {\n if (typeof data !== \"object\" || data === null) return false;\n const msg = data as {\n type?: unknown;\n slug?: unknown;\n requestId?: unknown;\n offset?: unknown;\n limit?: unknown;\n fields?: unknown;\n prompt?: unknown;\n role?: unknown;\n };\n if (msg.slug !== handlers.slug) return false;\n if (msg.type === REMOTE_VIEW_MESSAGES.startChat) {\n const prompt = typeof msg.prompt === \"string\" ? msg.prompt.trim() : \"\";\n if (prompt) handlers.onStartChat?.(prompt, typeof msg.role === \"string\" ? msg.role : undefined);\n return true;\n }\n if (msg.type !== REMOTE_VIEW_MESSAGES.getItems || typeof msg.requestId !== \"string\") return false;\n const request: RemoteViewPageRequest = { offset: clampOffset(msg.offset), limit: clampLimit(msg.limit), fields: normalizeFields(msg.fields) };\n await answerGetItems(msg.requestId, request, handlers, reply);\n return true;\n}\n"],"mappings":"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"}
@@ -546,7 +546,7 @@ function sanitizeForPrompt(value) {
546
546
  let prev;
547
547
  do {
548
548
  prev = current;
549
- current = current.replace(/<[^>]*>/g, "");
549
+ current = current.replace(/<[^>]{0,10000}>/g, "");
550
550
  } while (current !== prev);
551
551
  return current.replace(/`/g, "'").replace(/\$\{/g, "\\${");
552
552
  }
@@ -570,7 +570,7 @@ function sanitizeDeep(value) {
570
570
  * collections, so it reflects what the host actually reads/writes. */
571
571
  function promptPathsFor(collection, workspaceRoot) {
572
572
  const rel = node_path.default.relative(workspaceRoot, collection.skillDir);
573
- const skillDir = rel === "" || rel.startsWith("..") ? collection.skillDir : rel;
573
+ const skillDir = (rel === "" || rel.startsWith("..") ? collection.skillDir : rel).split(node_path.default.sep).join("/");
574
574
  return {
575
575
  slug: collection.slug,
576
576
  dataPath: collection.schema.dataPath,
@@ -859,6 +859,7 @@ var CustomViewSchema = zod.z.object({
859
859
  id: zod.z.string().trim().min(1),
860
860
  label: zod.z.string().trim().min(1),
861
861
  icon: zod.z.string().trim().min(1).optional(),
862
+ target: zod.z.enum(["desktop", "mobile"]).optional(),
862
863
  file: zod.z.string().trim().min(1).refine(require_collection_paths.isSafeCustomViewPath, "must be a safe path under `views/` ending in `.html` (e.g. `views/year.html`; no `..`, no leading `/`, no backslash)"),
863
864
  i18n: zod.z.string().trim().min(1).refine(require_collection_paths.isSafeCustomViewI18nPath, "must be a safe path under `views/` ending in `.i18n.json` (e.g. `views/year.i18n.json`; no `..`, no leading `/`, no backslash)").optional(),
864
865
  capabilities: zod.z.array(zod.z.enum(["read", "write"])).optional()
@@ -2127,4 +2128,4 @@ Object.defineProperty(exports, "writeItem", {
2127
2128
  }
2128
2129
  });
2129
2130
 
2130
- //# sourceMappingURL=server-cmnH6g2O.cjs.map
2131
+ //# sourceMappingURL=server-Ch1cWphH.cjs.map