@mulmoclaude/core 0.6.0 → 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.
@@ -1,6 +1,8 @@
1
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;
2
+ * exposes it as `__MC_VIEW.protocol` so a parent can refuse a stale view.
3
+ * v2 (phase 4) adds the mutate pair below — a backward-compatible superset,
4
+ * so a v1 (read-only) parent still serves get-items/start-chat unchanged. */
5
+ export declare const REMOTE_VIEW_PROTOCOL = 2;
4
6
  /** postMessage types between the sandboxed view and its parent page.
5
7
  * `startChat` reuses the desktop custom-view message type on purpose — the
6
8
  * desktop parent already understands it. */
@@ -9,6 +11,10 @@ export declare const REMOTE_VIEW_MESSAGES: {
9
11
  readonly getItems: "mc-remote-get-items";
10
12
  /** parent → view: the reply ({ requestId, ok, page | error }). */
11
13
  readonly items: "mc-remote-items";
14
+ /** view → parent: mutate one record ({ requestId, op: "update"|"delete", id, patch? }). */
15
+ readonly mutate: "mc-remote-mutate";
16
+ /** parent → view: the mutate reply ({ requestId, ok, result | error }). */
17
+ readonly mutateResult: "mc-remote-mutate-result";
12
18
  /** view → parent: open a new chat with a prefilled, NOT auto-sent draft. */
13
19
  readonly startChat: "mc-start-chat";
14
20
  };
@@ -75,12 +81,34 @@ export interface RemoteViewBoot {
75
81
  /** Host-picked, locale-filtered flat string map (same contract as the
76
82
  * desktop custom-view dict). */
77
83
  dict?: Record<string, string>;
84
+ /** True when the view declared a mutable surface (`editableFields` and/or
85
+ * `allowDelete`). Gates the client-side `updateItem`/`deleteItem` install
86
+ * only — the host re-enforces the actual policy on every mutate. */
87
+ writable?: boolean;
78
88
  }
79
89
  /** Wrap a view's HTML into the sandboxed srcdoc: CSP meta + `__MC_VIEW` boot +
80
90
  * bridge bootstrap injected at the start of `<head>` (before any view
81
91
  * script). Runs HOST-side (`getRemoteView`) so the phone and the desktop
82
92
  * preview receive the identical finished artifact. */
83
93
  export declare function buildRemoteViewSrcdoc(html: string, boot: RemoteViewBoot): string;
94
+ /** A normalized mutate request handed to a parent's `onMutate`
95
+ * (`handleRemoteViewMessage` validates op/id/patch first). `update` carries a
96
+ * partial record; the HOST decides which keys are actually writable. */
97
+ export type RemoteViewMutateRequest = {
98
+ op: "update";
99
+ id: string;
100
+ patch: Record<string, unknown>;
101
+ } | {
102
+ op: "delete";
103
+ id: string;
104
+ };
105
+ /** The resolved value of a mutate: the merged record for an update, the removed
106
+ * id for a delete. Sent back to the view as the `mc-remote-mutate-result`
107
+ * `result`. */
108
+ export interface RemoteViewMutateResult {
109
+ item?: RemoteViewItem;
110
+ id?: string;
111
+ }
84
112
  /** What a parent page provides to answer the sandboxed view. Deliberately
85
113
  * minimal — exactly the phone runtime's capabilities, nothing more, so the
86
114
  * desktop preview can never exceed what works on the phone. */
@@ -88,9 +116,21 @@ export interface RemoteViewBridgeHandlers {
88
116
  slug: string;
89
117
  /** Answer one normalized page request (already clamped + fields-cleaned). */
90
118
  getPage: (request: RemoteViewPageRequest) => Promise<RemoteViewPage> | RemoteViewPage;
119
+ /** Apply one normalized mutate (update/delete). Omit on a read-only parent —
120
+ * the handler then replies `ok: false, "this view is read-only"`. The parent
121
+ * forwards to the host (which enforces the write policy authoritatively). */
122
+ onMutate?: (request: RemoteViewMutateRequest) => Promise<RemoteViewMutateResult> | RemoteViewMutateResult;
91
123
  /** Relay a `startChat` draft; omit on a parent without a chat surface. */
92
124
  onStartChat?: (prompt: string, role?: string) => void;
93
125
  }
126
+ /** Coerce an untyped `mc-remote-mutate` payload to a normalized request, or
127
+ * null when it is malformed (unknown op, missing id, non-object update patch —
128
+ * the parent then replies with an `"invalid mutate request"` error). */
129
+ export declare function normalizeMutate(data: {
130
+ op?: unknown;
131
+ id?: unknown;
132
+ patch?: unknown;
133
+ }): RemoteViewMutateRequest | null;
94
134
  type RemoteViewReply = (message: Record<string, unknown>) => void;
95
135
  /**
96
136
  * Handle one message-event payload from a sandboxed remote view. DOM- and
@@ -1,7 +1,9 @@
1
1
  //#region src/remote-view/index.ts
2
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;
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;
5
7
  /** postMessage types between the sandboxed view and its parent page.
6
8
  * `startChat` reuses the desktop custom-view message type on purpose — the
7
9
  * desktop parent already understands it. */
@@ -10,6 +12,10 @@ var REMOTE_VIEW_MESSAGES = {
10
12
  getItems: "mc-remote-get-items",
11
13
  /** parent → view: the reply ({ requestId, ok, page | error }). */
12
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",
13
19
  /** view → parent: open a new chat with a prefilled, NOT auto-sent draft. */
14
20
  startChat: "mc-start-chat"
15
21
  };
@@ -95,13 +101,21 @@ function buildRemoteViewCsp(cdns = SANDBOXED_VIEW_CDN_ALLOWLIST) {
95
101
  }
96
102
  /** The in-iframe bootstrap installed before any of the view's own scripts.
97
103
  * Owns the fiddly part of the contract — request/response correlation — so an
98
- * LLM-authored view only ever awaits `__MC_VIEW.getItems(...)`:
104
+ * LLM-authored view only ever awaits `__MC_VIEW.getItems(...)` /
105
+ * `.updateItem(...)` / `.deleteItem(...)`:
99
106
  *
100
107
  * - `getItems({ offset, limit, fields })`: posts an `mc-remote-get-items`
101
108
  * with a fresh `requestId`, resolves on the matching `mc-remote-items`
102
109
  * reply (validated to come from `window.parent`), rejects on `ok: false`
103
110
  * or after 30 s. targetOrigin `'*'` is safe: the request carries no secret
104
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.
105
119
  * - `startChat(prompt, role)`: same message type + semantics as the desktop
106
120
  * bridge — the parent opens a new chat with `prompt` prefilled as an
107
121
  * editable draft, never auto-sent.
@@ -111,7 +125,7 @@ function buildRemoteViewCsp(cdns = SANDBOXED_VIEW_CDN_ALLOWLIST) {
111
125
  * Self-contained one-line string (no `<`, no `<\/script>`, `${}` only for the
112
126
  * interpolated constants). */
113
127
  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);});};})();`;
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);});};})();`;
115
129
  }
116
130
  /** Wrap a view's HTML into the sandboxed srcdoc: CSP meta + `__MC_VIEW` boot +
117
131
  * bridge bootstrap injected at the start of `<head>` (before any view
@@ -123,11 +137,32 @@ function buildRemoteViewSrcdoc(html, boot) {
123
137
  locale: boot.locale ?? "",
124
138
  dict: boot.dict ?? {},
125
139
  target: "mobile",
126
- protocol: 1
140
+ protocol: 2,
141
+ writable: boot.writable ?? false
127
142
  }).replace(/</g, "\\u003c")};${remoteViewBootstrap()}<\/script>`;
128
143
  if (/<head\b[^>]*>/i.test(html)) return html.replace(/(<head\b[^>]*>)/i, `$1${injection}`);
129
144
  return `<!DOCTYPE html><html><head>${injection}</head><body>${html}</body></html>`;
130
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
+ }
131
166
  async function answerGetItems(requestId, request, handlers, reply) {
132
167
  try {
133
168
  const page = await handlers.getPage(request);
@@ -146,6 +181,33 @@ async function answerGetItems(requestId, request, handlers, reply) {
146
181
  });
147
182
  }
148
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
+ }
149
211
  /**
150
212
  * Handle one message-event payload from a sandboxed remote view. DOM- and
151
213
  * framework-free: the caller owns the `message` listener (and MUST verify
@@ -163,6 +225,20 @@ async function handleRemoteViewMessage(data, handlers, reply) {
163
225
  if (prompt) handlers.onStartChat?.(prompt, typeof msg.role === "string" ? msg.role : void 0);
164
226
  return true;
165
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
+ }
166
242
  if (msg.type !== REMOTE_VIEW_MESSAGES.getItems || typeof msg.requestId !== "string") return false;
167
243
  const request = {
168
244
  offset: clampOffset(msg.offset),
@@ -173,6 +249,6 @@ async function handleRemoteViewMessage(data, handlers, reply) {
173
249
  return true;
174
250
  }
175
251
  //#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 };
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 };
177
253
 
178
254
  //# sourceMappingURL=index.js.map
@@ -1 +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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"}
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":"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"}
@@ -862,7 +862,9 @@ var CustomViewSchema = zod.z.object({
862
862
  target: zod.z.enum(["desktop", "mobile"]).optional(),
863
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)"),
864
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(),
865
- 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()
866
868
  });
867
869
  var EveryLiteralSchema = zod.z.object({
868
870
  unit: zod.z.enum([
@@ -2128,4 +2130,4 @@ Object.defineProperty(exports, "writeItem", {
2128
2130
  }
2129
2131
  });
2130
2132
 
2131
- //# sourceMappingURL=server-Ch1cWphH.cjs.map
2133
+ //# sourceMappingURL=server-DBi5HmrM.cjs.map