@mulmoclaude/core 0.5.1 → 0.7.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,254 @@
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
+ * v2 (phase 4) adds the mutate pair below — a backward-compatible superset,
5
+ * so a v1 (read-only) parent still serves get-items/start-chat unchanged. */
6
+ var REMOTE_VIEW_PROTOCOL = 2;
7
+ /** postMessage types between the sandboxed view and its parent page.
8
+ * `startChat` reuses the desktop custom-view message type on purpose — the
9
+ * desktop parent already understands it. */
10
+ var REMOTE_VIEW_MESSAGES = {
11
+ /** view → parent: request one page of records ({ requestId, offset, limit, fields }). */
12
+ getItems: "mc-remote-get-items",
13
+ /** parent → view: the reply ({ requestId, ok, page | error }). */
14
+ items: "mc-remote-items",
15
+ /** view → parent: mutate one record ({ requestId, op: "update"|"delete", id, patch? }). */
16
+ mutate: "mc-remote-mutate",
17
+ /** parent → view: the mutate reply ({ requestId, ok, result | error }). */
18
+ mutateResult: "mc-remote-mutate-result",
19
+ /** view → parent: open a new chat with a prefilled, NOT auto-sent draft. */
20
+ startChat: "mc-start-chat"
21
+ };
22
+ /** Pagination defaults — mirrored by the phase-2 record handlers
23
+ * (`server/remoteHost/handlers/collectionPage.ts` imports these) so a view
24
+ * page can never outgrow what the command channel itself serves. */
25
+ var DEFAULT_PAGE_LIMIT = 50;
26
+ var MAX_PAGE_LIMIT = 200;
27
+ /** Hard cap on the wrapped srcdoc: it travels to the phone INSIDE a Firestore
28
+ * command document (1 MiB total), so leave envelope headroom. */
29
+ var REMOTE_VIEW_MAX_BYTES = 9e5;
30
+ /** In-iframe `getItems` timeout — matches the remote client's `callHost`
31
+ * response timeout so the two layers give up together. */
32
+ var GET_ITEMS_TIMEOUT_MS = 3e4;
33
+ var SANDBOXED_VIEW_CDN_ALLOWLIST = [
34
+ "https://cdn.jsdelivr.net",
35
+ "https://unpkg.com",
36
+ "https://cdnjs.cloudflare.com",
37
+ "https://fonts.googleapis.com",
38
+ "https://fonts.gstatic.com",
39
+ "https://cdn.plot.ly"
40
+ ];
41
+ var toInt = (value) => {
42
+ const num = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
43
+ return Number.isFinite(num) ? Math.floor(num) : null;
44
+ };
45
+ /** Coerce a channel/postMessage offset (arrives as untyped JSON) to a non-negative int. */
46
+ var clampOffset = (value) => Math.max(0, toInt(value) ?? 0);
47
+ /** Coerce a channel/postMessage limit to [1, MAX_PAGE_LIMIT] (default 50). */
48
+ var clampLimit = (value) => {
49
+ const num = toInt(value);
50
+ if (num === null || num <= 0) return 50;
51
+ return Math.min(num, 200);
52
+ };
53
+ /** Coerce a `fields` projection list from untyped message JSON. */
54
+ var normalizeFields = (value) => {
55
+ if (!Array.isArray(value)) return void 0;
56
+ const cleaned = value.filter((entry) => typeof entry === "string").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
57
+ return cleaned.length > 0 ? cleaned : void 0;
58
+ };
59
+ /** Keep only `fields` (+ always the primary key) on each record. Parents apply
60
+ * this uniformly — the desktop preview via `pageFromItems`, the phone parent
61
+ * over the page it fetched through the channel — so a view sees the same
62
+ * projection everywhere. No-op without `fields`. */
63
+ function projectItems(items, fields, primaryKey) {
64
+ if (!fields || fields.length === 0) return items;
65
+ const keep = /* @__PURE__ */ new Set([primaryKey, ...fields]);
66
+ return items.map((item) => Object.fromEntries(Object.entries(item).filter(([key]) => keep.has(key))));
67
+ }
68
+ /** Answer a page request from an already-loaded record array (the desktop
69
+ * preview's data source): slice + project. Observable behavior matches the
70
+ * phone paging over the command channel. */
71
+ function pageFromItems(items, request, primaryKey) {
72
+ return {
73
+ items: projectItems(items.slice(request.offset, request.offset + request.limit), request.fields, primaryKey),
74
+ total: items.length,
75
+ offset: request.offset,
76
+ limit: request.limit
77
+ };
78
+ }
79
+ /**
80
+ * CSP for a remote (mobile) custom view. Stricter than the desktop custom-view
81
+ * policy: the view's data arrives over postMessage, so `connect-src` is
82
+ * `'none'` — no fetch / XHR / WebSocket / sendBeacon to ANY origin, which
83
+ * closes the bidirectional-exfiltration channel completely (there is no token
84
+ * to steal either). Script/style/font keep the curated CDN allowlist (the
85
+ * phone can reach the internet; only the host is unreachable), and
86
+ * `img-src`/`media-src` allow any `https:` host so record image/media URLs
87
+ * render — the same knowingly-accepted one-way GET-exfil tradeoff as the
88
+ * desktop policy (see buildCustomViewCsp in src/utils/html/previewCsp.ts).
89
+ */
90
+ function buildRemoteViewCsp(cdns = SANDBOXED_VIEW_CDN_ALLOWLIST) {
91
+ const cdnList = cdns.join(" ");
92
+ return [
93
+ "default-src 'none'",
94
+ `script-src 'unsafe-inline' ${cdnList}`,
95
+ `style-src 'unsafe-inline' ${cdnList}`,
96
+ `font-src ${cdnList}`,
97
+ `img-src ${cdnList} data: blob: https:`,
98
+ "media-src https: data: blob:",
99
+ "connect-src 'none'"
100
+ ].join("; ");
101
+ }
102
+ /** The in-iframe bootstrap installed before any of the view's own scripts.
103
+ * Owns the fiddly part of the contract — request/response correlation — so an
104
+ * LLM-authored view only ever awaits `__MC_VIEW.getItems(...)` /
105
+ * `.updateItem(...)` / `.deleteItem(...)`:
106
+ *
107
+ * - `getItems({ offset, limit, fields })`: posts an `mc-remote-get-items`
108
+ * with a fresh `requestId`, resolves on the matching `mc-remote-items`
109
+ * reply (validated to come from `window.parent`), rejects on `ok: false`
110
+ * or after 30 s. targetOrigin `'*'` is safe: the request carries no secret
111
+ * and the parent is by construction the party supplying the data.
112
+ * - `updateItem(id, patch)` / `deleteItem(id)` (phase 4): post an
113
+ * `mc-remote-mutate` and resolve on the matching `mc-remote-mutate-result`,
114
+ * sharing the same `call()` correlation as `getItems`. Installed ONLY when
115
+ * the host set `writable` (the view declared `editableFields`/`allowDelete`);
116
+ * otherwise both reject `"this view is read-only"` so a mis-declared view
117
+ * fails loudly instead of silently no-op'ing. The HOST still re-derives and
118
+ * enforces the write policy — `writable` only gates the client surface.
119
+ * - `startChat(prompt, role)`: same message type + semantics as the desktop
120
+ * bridge — the parent opens a new chat with `prompt` prefilled as an
121
+ * editable draft, never auto-sent.
122
+ * - `t(key, named)`: the same vue-i18n-compatible dict helper as the desktop
123
+ * bootstrap (named interpolation only), over the host-picked `dict`.
124
+ *
125
+ * Self-contained one-line string (no `<`, no `<\/script>`, `${}` only for the
126
+ * interpolated constants). */
127
+ function remoteViewBootstrap() {
128
+ 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)return;if(d.type!=='${REMOTE_VIEW_MESSAGES.items}'&&d.type!=='${REMOTE_VIEW_MESSAGES.mutateResult}')return;var p=pend[d.requestId];if(!p)return;delete pend[d.requestId];clearTimeout(p.timer);if(d.ok)p.resolve(d.type==='${REMOTE_VIEW_MESSAGES.items}'?d.page:d.result);else p.reject(new Error(typeof d.error==='string'?d.error:'request failed'));});function call(type,payload){return new Promise(function(resolve,reject){var id='q'+(++seq);var timer=setTimeout(function(){delete pend[id];reject(new Error(type+' timed out'));},${GET_ITEMS_TIMEOUT_MS});pend[id]={resolve:resolve,reject:reject,timer:timer};var m={type:type,slug:v.slug,requestId:id};for(var k in payload){m[k]=payload[k];}window.parent.postMessage(m,'*');});}v.getItems=function(opts){opts=opts&&typeof opts==='object'?opts:{};return call('${REMOTE_VIEW_MESSAGES.getItems}',{offset:opts.offset,limit:opts.limit,fields:opts.fields});};if(v.writable){v.updateItem=function(id,patch){return call('${REMOTE_VIEW_MESSAGES.mutate}',{op:'update',id:String(id),patch:patch&&typeof patch==='object'?patch:{}});};v.deleteItem=function(id){return call('${REMOTE_VIEW_MESSAGES.mutate}',{op:'delete',id:String(id)});};}else{v.updateItem=v.deleteItem=function(){return Promise.reject(new Error('this view is read-only'));};}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);});};})();`;
129
+ }
130
+ /** Wrap a view's HTML into the sandboxed srcdoc: CSP meta + `__MC_VIEW` boot +
131
+ * bridge bootstrap injected at the start of `<head>` (before any view
132
+ * script). Runs HOST-side (`getRemoteView`) so the phone and the desktop
133
+ * preview receive the identical finished artifact. */
134
+ function buildRemoteViewSrcdoc(html, boot) {
135
+ const injection = `${`<meta http-equiv="Content-Security-Policy" content="${buildRemoteViewCsp()}">`}<script>window.__MC_VIEW=${JSON.stringify({
136
+ slug: boot.slug,
137
+ locale: boot.locale ?? "",
138
+ dict: boot.dict ?? {},
139
+ target: "mobile",
140
+ protocol: 2,
141
+ writable: boot.writable ?? false
142
+ }).replace(/</g, "\\u003c")};${remoteViewBootstrap()}<\/script>`;
143
+ if (/<head\b[^>]*>/i.test(html)) return html.replace(/(<head\b[^>]*>)/i, `$1${injection}`);
144
+ return `<!DOCTYPE html><html><head>${injection}</head><body>${html}</body></html>`;
145
+ }
146
+ /** Coerce an untyped `mc-remote-mutate` payload to a normalized request, or
147
+ * null when it is malformed (unknown op, missing id, non-object update patch —
148
+ * the parent then replies with an `"invalid mutate request"` error). */
149
+ function normalizeMutate(data) {
150
+ const itemId = typeof data.id === "string" ? data.id : typeof data.id === "number" && Number.isFinite(data.id) ? String(data.id) : "";
151
+ if (!itemId) return null;
152
+ if (data.op === "delete") return {
153
+ op: "delete",
154
+ id: itemId
155
+ };
156
+ if (data.op === "update") {
157
+ if (typeof data.patch !== "object" || data.patch === null || Array.isArray(data.patch)) return null;
158
+ return {
159
+ op: "update",
160
+ id: itemId,
161
+ patch: data.patch
162
+ };
163
+ }
164
+ return null;
165
+ }
166
+ async function answerGetItems(requestId, request, handlers, reply) {
167
+ try {
168
+ const page = await handlers.getPage(request);
169
+ reply({
170
+ type: REMOTE_VIEW_MESSAGES.items,
171
+ requestId,
172
+ ok: true,
173
+ page
174
+ });
175
+ } catch (err) {
176
+ reply({
177
+ type: REMOTE_VIEW_MESSAGES.items,
178
+ requestId,
179
+ ok: false,
180
+ error: err instanceof Error ? err.message : String(err)
181
+ });
182
+ }
183
+ }
184
+ async function answerMutate(requestId, request, handlers, reply) {
185
+ if (!handlers.onMutate) {
186
+ reply({
187
+ type: REMOTE_VIEW_MESSAGES.mutateResult,
188
+ requestId,
189
+ ok: false,
190
+ error: "this view is read-only"
191
+ });
192
+ return;
193
+ }
194
+ try {
195
+ const result = await handlers.onMutate(request);
196
+ reply({
197
+ type: REMOTE_VIEW_MESSAGES.mutateResult,
198
+ requestId,
199
+ ok: true,
200
+ result
201
+ });
202
+ } catch (err) {
203
+ reply({
204
+ type: REMOTE_VIEW_MESSAGES.mutateResult,
205
+ requestId,
206
+ ok: false,
207
+ error: err instanceof Error ? err.message : String(err)
208
+ });
209
+ }
210
+ }
211
+ /**
212
+ * Handle one message-event payload from a sandboxed remote view. DOM- and
213
+ * framework-free: the caller owns the `message` listener (and MUST verify
214
+ * `event.source === iframe.contentWindow` before calling), `reply` posts the
215
+ * response back into the iframe (targetOrigin `"*"` — the sandboxed document's
216
+ * origin is opaque, so nothing else can match). Returns true when the payload
217
+ * was a remote-view request for this slug (callers ignore everything else).
218
+ */
219
+ async function handleRemoteViewMessage(data, handlers, reply) {
220
+ if (typeof data !== "object" || data === null) return false;
221
+ const msg = data;
222
+ if (msg.slug !== handlers.slug) return false;
223
+ if (msg.type === REMOTE_VIEW_MESSAGES.startChat) {
224
+ const prompt = typeof msg.prompt === "string" ? msg.prompt.trim() : "";
225
+ if (prompt) handlers.onStartChat?.(prompt, typeof msg.role === "string" ? msg.role : void 0);
226
+ return true;
227
+ }
228
+ if (msg.type === REMOTE_VIEW_MESSAGES.mutate && typeof msg.requestId === "string") {
229
+ const request = normalizeMutate(msg);
230
+ if (!request) {
231
+ reply({
232
+ type: REMOTE_VIEW_MESSAGES.mutateResult,
233
+ requestId: msg.requestId,
234
+ ok: false,
235
+ error: "invalid mutate request"
236
+ });
237
+ return true;
238
+ }
239
+ await answerMutate(msg.requestId, request, handlers, reply);
240
+ return true;
241
+ }
242
+ if (msg.type !== REMOTE_VIEW_MESSAGES.getItems || typeof msg.requestId !== "string") return false;
243
+ const request = {
244
+ offset: clampOffset(msg.offset),
245
+ limit: clampLimit(msg.limit),
246
+ fields: normalizeFields(msg.fields)
247
+ };
248
+ await answerGetItems(msg.requestId, request, handlers, reply);
249
+ return true;
250
+ }
251
+ //#endregion
252
+ 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, normalizeMutate, pageFromItems, projectItems };
253
+
254
+ //# 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.\n * v2 (phase 4) adds the mutate pair below — a backward-compatible superset,\n * so a v1 (read-only) parent still serves get-items/start-chat unchanged. */\nexport const REMOTE_VIEW_PROTOCOL = 2;\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: mutate one record ({ requestId, op: \"update\"|\"delete\", id, patch? }). */\n mutate: \"mc-remote-mutate\",\n /** parent → view: the mutate reply ({ requestId, ok, result | error }). */\n mutateResult: \"mc-remote-mutate-result\",\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 * `.updateItem(...)` / `.deleteItem(...)`:\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 * - `updateItem(id, patch)` / `deleteItem(id)` (phase 4): post an\n * `mc-remote-mutate` and resolve on the matching `mc-remote-mutate-result`,\n * sharing the same `call()` correlation as `getItems`. Installed ONLY when\n * the host set `writable` (the view declared `editableFields`/`allowDelete`);\n * otherwise both reject `\"this view is read-only\"` so a mis-declared view\n * fails loudly instead of silently no-op'ing. The HOST still re-derives and\n * enforces the write policy — `writable` only gates the client surface.\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)return;if(d.type!=='${REMOTE_VIEW_MESSAGES.items}'&&d.type!=='${REMOTE_VIEW_MESSAGES.mutateResult}')return;var p=pend[d.requestId];if(!p)return;delete pend[d.requestId];clearTimeout(p.timer);if(d.ok)p.resolve(d.type==='${REMOTE_VIEW_MESSAGES.items}'?d.page:d.result);else p.reject(new Error(typeof d.error==='string'?d.error:'request failed'));});function call(type,payload){return new Promise(function(resolve,reject){var id='q'+(++seq);var timer=setTimeout(function(){delete pend[id];reject(new Error(type+' timed out'));},${GET_ITEMS_TIMEOUT_MS});pend[id]={resolve:resolve,reject:reject,timer:timer};var m={type:type,slug:v.slug,requestId:id};for(var k in payload){m[k]=payload[k];}window.parent.postMessage(m,'*');});}v.getItems=function(opts){opts=opts&&typeof opts==='object'?opts:{};return call('${REMOTE_VIEW_MESSAGES.getItems}',{offset:opts.offset,limit:opts.limit,fields:opts.fields});};if(v.writable){v.updateItem=function(id,patch){return call('${REMOTE_VIEW_MESSAGES.mutate}',{op:'update',id:String(id),patch:patch&&typeof patch==='object'?patch:{}});};v.deleteItem=function(id){return call('${REMOTE_VIEW_MESSAGES.mutate}',{op:'delete',id:String(id)});};}else{v.updateItem=v.deleteItem=function(){return Promise.reject(new Error('this view is read-only'));};}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 /** True when the view declared a mutable surface (`editableFields` and/or\n * `allowDelete`). Gates the client-side `updateItem`/`deleteItem` install\n * only — the host re-enforces the actual policy on every mutate. */\n writable?: boolean;\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 writable: boot.writable ?? false,\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/** A normalized mutate request handed to a parent's `onMutate`\n * (`handleRemoteViewMessage` validates op/id/patch first). `update` carries a\n * partial record; the HOST decides which keys are actually writable. */\nexport type RemoteViewMutateRequest = { op: \"update\"; id: string; patch: Record<string, unknown> } | { op: \"delete\"; id: string };\n\n/** The resolved value of a mutate: the merged record for an update, the removed\n * id for a delete. Sent back to the view as the `mc-remote-mutate-result`\n * `result`. */\nexport interface RemoteViewMutateResult {\n item?: RemoteViewItem;\n id?: string;\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 /** Apply one normalized mutate (update/delete). Omit on a read-only parent —\n * the handler then replies `ok: false, \"this view is read-only\"`. The parent\n * forwards to the host (which enforces the write policy authoritatively). */\n onMutate?: (request: RemoteViewMutateRequest) => Promise<RemoteViewMutateResult> | RemoteViewMutateResult;\n /** Relay a `startChat` draft; omit on a parent without a chat surface. */\n onStartChat?: (prompt: string, role?: string) => void;\n}\n\n/** Coerce an untyped `mc-remote-mutate` payload to a normalized request, or\n * null when it is malformed (unknown op, missing id, non-object update patch —\n * the parent then replies with an `\"invalid mutate request\"` error). */\nexport function normalizeMutate(data: { op?: unknown; id?: unknown; patch?: unknown }): RemoteViewMutateRequest | null {\n const itemId = typeof data.id === \"string\" ? data.id : typeof data.id === \"number\" && Number.isFinite(data.id) ? String(data.id) : \"\";\n if (!itemId) return null;\n if (data.op === \"delete\") return { op: \"delete\", id: itemId };\n if (data.op === \"update\") {\n if (typeof data.patch !== \"object\" || data.patch === null || Array.isArray(data.patch)) return null;\n return { op: \"update\", id: itemId, patch: data.patch as Record<string, unknown> };\n }\n return null;\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\nasync function answerMutate(requestId: string, request: RemoteViewMutateRequest, handlers: RemoteViewBridgeHandlers, reply: RemoteViewReply): Promise<void> {\n if (!handlers.onMutate) {\n reply({ type: REMOTE_VIEW_MESSAGES.mutateResult, requestId, ok: false, error: \"this view is read-only\" });\n return;\n }\n try {\n const result = await handlers.onMutate(request);\n reply({ type: REMOTE_VIEW_MESSAGES.mutateResult, requestId, ok: true, result });\n } catch (err) {\n reply({ type: REMOTE_VIEW_MESSAGES.mutateResult, 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 op?: unknown;\n id?: unknown;\n patch?: 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.mutate && typeof msg.requestId === \"string\") {\n const request = normalizeMutate(msg);\n if (!request) {\n reply({ type: REMOTE_VIEW_MESSAGES.mutateResult, requestId: msg.requestId, ok: false, error: \"invalid mutate request\" });\n return true;\n }\n await answerMutate(msg.requestId, request, handlers, reply);\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":";;;;;AAcA,IAAa,uBAAuB;;;;AAKpC,IAAa,uBAAuB;;CAElC,UAAU;;CAEV,OAAO;;CAEP,QAAQ;;CAER,cAAc;;CAEd,WAAW;AACb;;;;AAKA,IAAa,qBAAqB;AAClC,IAAa,iBAAiB;;;AAI9B,IAAa,wBAAwB;;;AAIrC,IAAM,uBAAuB;AAQ7B,IAAa,+BAAkD;CAC7D;CACA;CACA;CACA;CACA;CAEA;AACF;AAEA,IAAM,SAAS,UAAkC;CAC/C,MAAM,MAAM,OAAO,UAAU,WAAW,QAAQ,OAAO,UAAU,WAAW,OAAO,KAAK,IAAI;CAC5F,OAAO,OAAO,SAAS,GAAG,IAAI,KAAK,MAAM,GAAG,IAAI;AAClD;;AAGA,IAAa,eAAe,UAA2B,KAAK,IAAI,GAAG,MAAM,KAAK,KAAK,CAAC;;AAGpF,IAAa,cAAc,UAA2B;CACpD,MAAM,MAAM,MAAM,KAAK;CACvB,IAAI,QAAQ,QAAQ,OAAO,GAAG,OAAA;CAC9B,OAAO,KAAK,IAAI,KAAA,GAAmB;AACrC;;AAGA,IAAa,mBAAmB,UAAyC;CACvE,IAAI,CAAC,MAAM,QAAQ,KAAK,GAAG,OAAO,KAAA;CAClC,MAAM,UAAU,MACb,QAAQ,UAA2B,OAAO,UAAU,QAAQ,CAAC,CAC7D,KAAK,UAAU,MAAM,KAAK,CAAC,CAAC,CAC5B,QAAQ,UAAU,MAAM,SAAS,CAAC;CACrC,OAAO,QAAQ,SAAS,IAAI,UAAU,KAAA;AACxC;;;;;AA2BA,SAAgB,aAAa,OAAyB,QAA8B,YAAsC;CACxH,IAAI,CAAC,UAAU,OAAO,WAAW,GAAG,OAAO;CAC3C,MAAM,uBAAO,IAAI,IAAI,CAAC,YAAY,GAAG,MAAM,CAAC;CAC5C,OAAO,MAAM,KAAK,SAAS,OAAO,YAAY,OAAO,QAAQ,IAAI,CAAC,CAAC,QAAQ,CAAC,SAAS,KAAK,IAAI,GAAG,CAAC,CAAC,CAAC;AACtG;;;;AAKA,SAAgB,cAAc,OAAyB,SAAgC,YAAoC;CAEzH,OAAO;EAAE,OAAO,aADE,MAAM,MAAM,QAAQ,QAAQ,QAAQ,SAAS,QAAQ,KAC1C,GAAW,QAAQ,QAAQ,UAAU;EAAG,OAAO,MAAM;EAAQ,QAAQ,QAAQ;EAAQ,OAAO,QAAQ;CAAM;AACzI;;;;;;;;;;;;AAaA,SAAgB,mBAAmB,OAA0B,8BAAsC;CACjG,MAAM,UAAU,KAAK,KAAK,GAAG;CAC7B,OAAO;EACL;EACA,8BAA8B;EAC9B,6BAA6B;EAC7B,YAAY;EACZ,WAAW,QAAQ;EACnB;EACA;CACF,CAAC,CAAC,KAAK,IAAI;AACb;;;;;;;;;;;;;;;;;;;;;;;;;;AA2BA,SAAS,sBAA8B;CACrC,OAAO,4KAA4K,qBAAqB,MAAM,eAAe,qBAAqB,aAAa,2HAA2H,qBAAqB,MAAM,uRAAuR,qBAAqB,iQAAiQ,qBAAqB,SAAS,4HAA4H,qBAAqB,OAAO,wHAAwH,qBAAqB,OAAO,+MAA+M,qBAAqB,UAAU;AAC5/C;;;;;AAqBA,SAAgB,sBAAsB,MAAc,MAA8B;CAYhF,MAAM,YAAY,GAAG,uDAXkD,mBAAmB,EAAE,IAW/D,2BARhB,KAAK,UAAU;EAC1B,MAAM,KAAK;EACX,QAAQ,KAAK,UAAU;EACvB,MAAM,KAAK,QAAQ,CAAC;EACpB,QAAQ;EACR,UAAA;EACA,UAAU,KAAK,YAAY;CAC7B,CAAC,CAAC,CAAC,QAAQ,MAAM,SACuC,EAAK,GAAG,oBAAoB,EAAE;CACtF,IAAI,iBAAiB,KAAK,IAAI,GAC5B,OAAO,KAAK,QAAQ,oBAAoB,KAAK,WAAW;CAE1D,OAAO,8BAA8B,UAAU,eAAe,KAAK;AACrE;;;;AAiCA,SAAgB,gBAAgB,MAAuF;CACrH,MAAM,SAAS,OAAO,KAAK,OAAO,WAAW,KAAK,KAAK,OAAO,KAAK,OAAO,YAAY,OAAO,SAAS,KAAK,EAAE,IAAI,OAAO,KAAK,EAAE,IAAI;CACnI,IAAI,CAAC,QAAQ,OAAO;CACpB,IAAI,KAAK,OAAO,UAAU,OAAO;EAAE,IAAI;EAAU,IAAI;CAAO;CAC5D,IAAI,KAAK,OAAO,UAAU;EACxB,IAAI,OAAO,KAAK,UAAU,YAAY,KAAK,UAAU,QAAQ,MAAM,QAAQ,KAAK,KAAK,GAAG,OAAO;EAC/F,OAAO;GAAE,IAAI;GAAU,IAAI;GAAQ,OAAO,KAAK;EAAiC;CAClF;CACA,OAAO;AACT;AAEA,eAAe,eAAe,WAAmB,SAAgC,UAAoC,OAAuC;CAC1J,IAAI;EACF,MAAM,OAAO,MAAM,SAAS,QAAQ,OAAO;EAC3C,MAAM;GAAE,MAAM,qBAAqB;GAAO;GAAW,IAAI;GAAM;EAAK,CAAC;CACvE,SAAS,KAAK;EACZ,MAAM;GAAE,MAAM,qBAAqB;GAAO;GAAW,IAAI;GAAO,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;EAAE,CAAC;CAC3H;AACF;AAEA,eAAe,aAAa,WAAmB,SAAkC,UAAoC,OAAuC;CAC1J,IAAI,CAAC,SAAS,UAAU;EACtB,MAAM;GAAE,MAAM,qBAAqB;GAAc;GAAW,IAAI;GAAO,OAAO;EAAyB,CAAC;EACxG;CACF;CACA,IAAI;EACF,MAAM,SAAS,MAAM,SAAS,SAAS,OAAO;EAC9C,MAAM;GAAE,MAAM,qBAAqB;GAAc;GAAW,IAAI;GAAM;EAAO,CAAC;CAChF,SAAS,KAAK;EACZ,MAAM;GAAE,MAAM,qBAAqB;GAAc;GAAW,IAAI;GAAO,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;EAAE,CAAC;CAClI;AACF;;;;;;;;;AAYA,eAAsB,wBAAwB,MAAe,UAAoC,OAA0C;CACzI,IAAI,OAAO,SAAS,YAAY,SAAS,MAAM,OAAO;CACtD,MAAM,MAAM;CAaZ,IAAI,IAAI,SAAS,SAAS,MAAM,OAAO;CACvC,IAAI,IAAI,SAAS,qBAAqB,WAAW;EAC/C,MAAM,SAAS,OAAO,IAAI,WAAW,WAAW,IAAI,OAAO,KAAK,IAAI;EACpE,IAAI,QAAQ,SAAS,cAAc,QAAQ,OAAO,IAAI,SAAS,WAAW,IAAI,OAAO,KAAA,CAAS;EAC9F,OAAO;CACT;CACA,IAAI,IAAI,SAAS,qBAAqB,UAAU,OAAO,IAAI,cAAc,UAAU;EACjF,MAAM,UAAU,gBAAgB,GAAG;EACnC,IAAI,CAAC,SAAS;GACZ,MAAM;IAAE,MAAM,qBAAqB;IAAc,WAAW,IAAI;IAAW,IAAI;IAAO,OAAO;GAAyB,CAAC;GACvH,OAAO;EACT;EACA,MAAM,aAAa,IAAI,WAAW,SAAS,UAAU,KAAK;EAC1D,OAAO;CACT;CACA,IAAI,IAAI,SAAS,qBAAqB,YAAY,OAAO,IAAI,cAAc,UAAU,OAAO;CAC5F,MAAM,UAAiC;EAAE,QAAQ,YAAY,IAAI,MAAM;EAAG,OAAO,WAAW,IAAI,KAAK;EAAG,QAAQ,gBAAgB,IAAI,MAAM;CAAE;CAC5I,MAAM,eAAe,IAAI,WAAW,SAAS,UAAU,KAAK;CAC5D,OAAO;AACT"}
@@ -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,9 +859,12 @@ 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
- capabilities: zod.z.array(zod.z.enum(["read", "write"])).optional()
865
+ capabilities: zod.z.array(zod.z.enum(["read", "write"])).optional(),
866
+ editableFields: zod.z.array(zod.z.string().trim().min(1)).optional(),
867
+ allowDelete: zod.z.boolean().optional()
865
868
  });
866
869
  var EveryLiteralSchema = zod.z.object({
867
870
  unit: zod.z.enum([
@@ -2127,4 +2130,4 @@ Object.defineProperty(exports, "writeItem", {
2127
2130
  }
2128
2131
  });
2129
2132
 
2130
- //# sourceMappingURL=server-cmnH6g2O.cjs.map
2133
+ //# sourceMappingURL=server-DBi5HmrM.cjs.map