@mulmoclaude/core 1.13.0 → 1.14.1
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.
- package/assets/helps/error-recovery.md +12 -1
- package/dist/atomic-C_7YpMiM.cjs.map +1 -1
- package/dist/atomic-DPpdrJzO.js.map +1 -1
- package/dist/{calendarGrid-CrGxeCOG.js → calendarGrid-Csy2rpjp.js} +65 -36
- package/dist/{calendarGrid-CrGxeCOG.js.map → calendarGrid-Csy2rpjp.js.map} +1 -1
- package/dist/{calendarGrid-CaS9er8i.cjs → calendarGrid-p8K-7cuB.cjs} +65 -36
- package/dist/{calendarGrid-CaS9er8i.cjs.map → calendarGrid-p8K-7cuB.cjs.map} +1 -1
- package/dist/collection/core/actionVisible.d.ts +3 -3
- package/dist/collection/core/completion.d.ts +5 -5
- package/dist/collection/core/schemaZ.d.ts +4 -4
- package/dist/collection/index.cjs +7 -10
- package/dist/collection/index.cjs.map +1 -1
- package/dist/collection/index.js +7 -10
- package/dist/collection/index.js.map +1 -1
- package/dist/collection/registry/registryIndex.d.ts +1 -1
- package/dist/collection/registry/server/index.cjs +15 -13
- package/dist/collection/registry/server/index.cjs.map +1 -1
- package/dist/collection/registry/server/index.js +15 -13
- package/dist/collection/registry/server/index.js.map +1 -1
- package/dist/collection/server/csvQuery.d.ts +7 -2
- package/dist/collection/server/csvStore.d.ts +2 -1
- package/dist/collection/server/discovery.d.ts +2 -2
- package/dist/collection/server/index.cjs +3 -3
- package/dist/collection/server/index.js +3 -3
- package/dist/collection/server/io.d.ts +3 -3
- package/dist/collection/server/manageTool.d.ts +3 -3
- package/dist/collection/server/store.d.ts +3 -3
- package/dist/collection/server/validate.d.ts +1 -1
- package/dist/collection-watchers/index.cjs +10 -25
- package/dist/collection-watchers/index.cjs.map +1 -1
- package/dist/collection-watchers/index.js +10 -25
- package/dist/collection-watchers/index.js.map +1 -1
- package/dist/{discovery-D-ZJ6a6z.js → discovery-BSedPW8A.js} +64 -36
- package/dist/discovery-BSedPW8A.js.map +1 -0
- package/dist/{discovery-DAoMnBYM.cjs → discovery-DSLgRQhN.cjs} +64 -36
- package/dist/discovery-DSLgRQhN.cjs.map +1 -0
- package/dist/{dist-21pMsI1a.cjs → dist-CsgSfWwR.cjs} +22 -1
- package/dist/{dist-21pMsI1a.cjs.map → dist-CsgSfWwR.cjs.map} +1 -1
- package/dist/{dist-DP1FTGnO.js → dist-D8zokgGo.js} +11 -2
- package/dist/{dist-DP1FTGnO.js.map → dist-D8zokgGo.js.map} +1 -1
- package/dist/feeds/index.cjs +2 -2
- package/dist/feeds/index.js +2 -2
- package/dist/feeds/server/host.d.ts +2 -2
- package/dist/feeds/server/index.cjs +35 -29
- package/dist/feeds/server/index.cjs.map +1 -1
- package/dist/feeds/server/index.js +35 -29
- package/dist/feeds/server/index.js.map +1 -1
- package/dist/feeds/server/refreshResult.d.ts +2 -2
- package/dist/files/index.cjs +3 -2
- package/dist/files/index.cjs.map +1 -1
- package/dist/files/index.js +3 -2
- package/dist/files/index.js.map +1 -1
- package/dist/google/calendar.d.ts +47 -33
- package/dist/google/collectionPush.d.ts +13 -8
- package/dist/google/collectionSync.d.ts +2 -2
- package/dist/google/index.cjs +63 -38
- package/dist/google/index.cjs.map +1 -1
- package/dist/google/index.d.ts +2 -2
- package/dist/google/index.js +61 -38
- package/dist/google/index.js.map +1 -1
- package/dist/{graph-DjE1Az1C.cjs → graph-BZJxiv1E.cjs} +10 -12
- package/dist/graph-BZJxiv1E.cjs.map +1 -0
- package/dist/{graph-BcwqiSpR.js → graph-Ce7xsDTt.js} +10 -12
- package/dist/graph-Ce7xsDTt.js.map +1 -0
- package/dist/{ingestTypes-D1GQdG8e.cjs → ingestTypes-CPR-h-OZ.cjs} +2 -2
- package/dist/{ingestTypes-D1GQdG8e.cjs.map → ingestTypes-CPR-h-OZ.cjs.map} +1 -1
- package/dist/{ingestTypes-XReG-li7.js → ingestTypes-F2IJhOLJ.js} +2 -2
- package/dist/{ingestTypes-XReG-li7.js.map → ingestTypes-F2IJhOLJ.js.map} +1 -1
- package/dist/notifier/index.cjs +1 -1
- package/dist/notifier/index.js +1 -1
- package/dist/notifier/types.d.ts +4 -4
- package/dist/{notifier-C3IhYxqh.cjs → notifier-CKFVVwDP.cjs} +36 -25
- package/dist/notifier-CKFVVwDP.cjs.map +1 -0
- package/dist/{notifier-CMFlw74b.js → notifier-CQD8NljP.js} +36 -25
- package/dist/notifier-CQD8NljP.js.map +1 -0
- package/dist/plugin-vue/index.cjs +4 -2
- package/dist/plugin-vue/index.cjs.map +1 -1
- package/dist/plugin-vue/index.js +4 -2
- package/dist/plugin-vue/index.js.map +1 -1
- package/dist/{project-B9NSyr6L.js → project-B0j_Vf_Q.js} +7 -2
- package/dist/project-B0j_Vf_Q.js.map +1 -0
- package/dist/{project-CCtT_fQr.cjs → project-C1ep9pvo.cjs} +7 -2
- package/dist/project-C1ep9pvo.cjs.map +1 -0
- package/dist/{promptSafety-FD8cQn3e.cjs → promptSafety-DUZ5BiW5.cjs} +106 -88
- package/dist/promptSafety-DUZ5BiW5.cjs.map +1 -0
- package/dist/{promptSafety-C--op_6a.js → promptSafety-DgHMF4Pz.js} +106 -88
- package/dist/promptSafety-DgHMF4Pz.js.map +1 -0
- package/dist/remote-host/index.cjs +2 -1
- package/dist/remote-host/index.d.ts +10 -0
- package/dist/remote-host/index.js +2 -2
- package/dist/remote-host/server/hostRunner.d.ts +7 -2
- package/dist/remote-host/server/index.cjs +14 -11
- package/dist/remote-host/server/index.cjs.map +1 -1
- package/dist/remote-host/server/index.js +13 -10
- package/dist/remote-host/server/index.js.map +1 -1
- package/dist/{remote-host-DkDVxNim.cjs → remote-host-CtQjagPt.cjs} +53 -1
- package/dist/remote-host-CtQjagPt.cjs.map +1 -0
- package/dist/remote-host-Dba4lF3Z.js +82 -0
- package/dist/remote-host-Dba4lF3Z.js.map +1 -0
- package/dist/remote-view/index.cjs +6 -4
- package/dist/remote-view/index.cjs.map +1 -1
- package/dist/remote-view/index.d.ts +1 -1
- package/dist/remote-view/index.js +6 -4
- package/dist/remote-view/index.js.map +1 -1
- package/dist/scheduler/adapter.d.ts +4 -4
- package/dist/scheduler/index.cjs +35 -3
- package/dist/scheduler/index.cjs.map +1 -1
- package/dist/scheduler/index.js +35 -3
- package/dist/scheduler/index.js.map +1 -1
- package/dist/scheduler/task-manager.d.ts +2 -2
- package/dist/{server-B_b0bLSW.cjs → server-1c6gB372.cjs} +54 -31
- package/dist/server-1c6gB372.cjs.map +1 -0
- package/dist/{server-Ci_A3chZ.js → server-Dv_L7l0y.js} +54 -31
- package/dist/server-Dv_L7l0y.js.map +1 -0
- package/dist/skill-bridge/index.cjs +3 -4
- package/dist/skill-bridge/index.cjs.map +1 -1
- package/dist/skill-bridge/index.js +3 -4
- package/dist/skill-bridge/index.js.map +1 -1
- package/dist/types-BKVZrtyc.js.map +1 -1
- package/dist/types-CNqkLT4M.cjs.map +1 -1
- package/dist/utils/fetch.cjs.map +1 -1
- package/dist/utils/fetch.d.ts +1 -1
- package/dist/utils/fetch.js.map +1 -1
- package/dist/utils/index.cjs +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/whisper/client.cjs.map +1 -1
- package/dist/whisper/client.d.ts +3 -3
- package/dist/whisper/client.js.map +1 -1
- package/dist/whisper/index.cjs +2 -6
- package/dist/whisper/index.cjs.map +1 -1
- package/dist/whisper/index.js +2 -6
- package/dist/whisper/index.js.map +1 -1
- package/dist/wiki/index.cjs +4 -4
- package/dist/wiki/index.cjs.map +1 -1
- package/dist/wiki/index.js +4 -4
- package/dist/wiki/index.js.map +1 -1
- package/dist/wiki/server/index.cjs +15 -12
- package/dist/wiki/server/index.cjs.map +1 -1
- package/dist/wiki/server/index.js +15 -12
- package/dist/wiki/server/index.js.map +1 -1
- package/package.json +6 -6
- package/dist/discovery-D-ZJ6a6z.js.map +0 -1
- package/dist/discovery-DAoMnBYM.cjs.map +0 -1
- package/dist/graph-BcwqiSpR.js.map +0 -1
- package/dist/graph-DjE1Az1C.cjs.map +0 -1
- package/dist/notifier-C3IhYxqh.cjs.map +0 -1
- package/dist/notifier-CMFlw74b.js.map +0 -1
- package/dist/project-B9NSyr6L.js.map +0 -1
- package/dist/project-CCtT_fQr.cjs.map +0 -1
- package/dist/promptSafety-C--op_6a.js.map +0 -1
- package/dist/promptSafety-FD8cQn3e.cjs.map +0 -1
- package/dist/remote-host-D_BRFHcI.js +0 -36
- package/dist/remote-host-D_BRFHcI.js.map +0 -1
- package/dist/remote-host-DkDVxNim.cjs.map +0 -1
- package/dist/server-B_b0bLSW.cjs.map +0 -1
- package/dist/server-Ci_A3chZ.js.map +0 -1
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { s as isRecord$1 } from "./dist-D8zokgGo.js";
|
|
2
|
+
import { collection, doc } from "firebase/firestore";
|
|
3
|
+
//#region src/remote-host/health.ts
|
|
4
|
+
var RUNNER_HEALTH_STATES = [
|
|
5
|
+
"online",
|
|
6
|
+
"reconnecting",
|
|
7
|
+
"offline"
|
|
8
|
+
];
|
|
9
|
+
var isRecord = (value) => typeof value === "object" && value !== null;
|
|
10
|
+
var isRunnerHealthState = (value) => RUNNER_HEALTH_STATES.some((state) => state === value);
|
|
11
|
+
/** Narrows a parsed HTTP payload. The client renders whatever this accepts, so a
|
|
12
|
+
* half-shaped health has to read as "no health reported" rather than as a state. */
|
|
13
|
+
var isRunnerHealth = (value) => isRecord(value) && isRunnerHealthState(value.state) && (value.lastError === null || typeof value.lastError === "string") && typeof value.changedAt === "number";
|
|
14
|
+
//#endregion
|
|
15
|
+
//#region src/remote-host/index.ts
|
|
16
|
+
/** Widen a JSON-shaped handler payload to the channel's `JsonObject`.
|
|
17
|
+
*
|
|
18
|
+
* Exists so the `Jsonify` reasoning above lives in ONE place. Before this,
|
|
19
|
+
* eight remote-host handlers each carried their own `as unknown as JsonObject`
|
|
20
|
+
* with the justification re-argued in eight slightly different comments —
|
|
21
|
+
* which is how a rule stops being reviewable. */
|
|
22
|
+
var toJsonObject = (payload) => payload;
|
|
23
|
+
var describeNonJson = (value) => {
|
|
24
|
+
if (typeof value === "number") return String(value);
|
|
25
|
+
if (typeof value === "object") return "a non-plain object";
|
|
26
|
+
return `a ${typeof value}`;
|
|
27
|
+
};
|
|
28
|
+
/** Anything carrying its own JSON form — `Date` above all — must be asked for
|
|
29
|
+
* it rather than walked, because walking a `Date`'s own enumerable keys finds
|
|
30
|
+
* none and flattens the timestamp to `{}`. This is the step `JSON.stringify`
|
|
31
|
+
* performs before it recurses, and the channel used to get it for free. */
|
|
32
|
+
var hasToJson = (value) => "toJSON" in value && typeof value.toJSON === "function";
|
|
33
|
+
var jsonRepresentationOf = (value) => hasToJson(value) ? value.toJSON() : value;
|
|
34
|
+
/** Rebuild `value` as JSON, or throw naming the property that cannot be. */
|
|
35
|
+
function toJsonValue(value, path) {
|
|
36
|
+
if (Array.isArray(value)) return toJsonItems(value, path);
|
|
37
|
+
if (isRecord$1(value)) {
|
|
38
|
+
const represented = jsonRepresentationOf(value);
|
|
39
|
+
if (represented !== value) return toJsonValue(represented, path);
|
|
40
|
+
return toJsonEntries(value, path);
|
|
41
|
+
}
|
|
42
|
+
return toJsonScalar(value, path);
|
|
43
|
+
}
|
|
44
|
+
/** JSON's four scalar forms. Anything else — a function, a class instance, a
|
|
45
|
+
* non-finite number — is what the channel cannot carry. */
|
|
46
|
+
function toJsonScalar(value, path) {
|
|
47
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
|
|
48
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
49
|
+
throw new Error(`${path} is ${describeNonJson(value)}, which JSON cannot represent`);
|
|
50
|
+
}
|
|
51
|
+
function toJsonItems(items, path) {
|
|
52
|
+
return Array.from(items, (entry, index) => entry === void 0 ? null : toJsonValue(entry, `${path}[${index}]`));
|
|
53
|
+
}
|
|
54
|
+
function toJsonEntries(record, path) {
|
|
55
|
+
const usable = Object.entries(record).filter(([, value]) => value !== void 0);
|
|
56
|
+
return Object.fromEntries(usable.map(([key, value]) => [key, toJsonValue(value, `${path}.${key}`)]));
|
|
57
|
+
}
|
|
58
|
+
/** Runtime counterpart to `toJsonObject`, for payloads whose values are typed
|
|
59
|
+
* `unknown` — a collection record, a projected view row — so no amount of
|
|
60
|
+
* mapped-type work can PROVE them JSON.
|
|
61
|
+
*
|
|
62
|
+
* Walks the payload and rebuilds it from the values it actually inspected, so
|
|
63
|
+
* the returned `JsonObject` is earned rather than asserted. Absent (`undefined`)
|
|
64
|
+
* properties are dropped exactly as `JSON.stringify` drops them; anything the
|
|
65
|
+
* channel could not carry — a function, a class instance, `NaN` — throws
|
|
66
|
+
* naming its path, instead of reaching Firestore as a silently mangled write. */
|
|
67
|
+
var coerceJsonObject = (payload) => toJsonEntries(payload, "payload");
|
|
68
|
+
var isExpired = (command, now) => typeof command.expiresAt === "number" && now >= command.expiresAt;
|
|
69
|
+
var byCreatedAt = (left, right) => (left.createdAt ?? 0) - (right.createdAt ?? 0);
|
|
70
|
+
var REMOTE_HOST_PROTOCOL_VERSION = 2;
|
|
71
|
+
var buildHostPresence = (channel, handlers, online) => ({
|
|
72
|
+
online,
|
|
73
|
+
hostId: channel.hostId,
|
|
74
|
+
protocolVersion: 2,
|
|
75
|
+
capabilities: Object.keys(handlers)
|
|
76
|
+
});
|
|
77
|
+
var commandsCollection = (firestore, channel) => collection(firestore, "users", channel.uid, "hosts", channel.hostId, "commands");
|
|
78
|
+
var hostDoc = (firestore, channel) => doc(firestore, "users", channel.uid, "hosts", channel.hostId);
|
|
79
|
+
//#endregion
|
|
80
|
+
export { commandsCollection as a, toJsonObject as c, isRunnerHealthState as d, coerceJsonObject as i, RUNNER_HEALTH_STATES as l, buildHostPresence as n, hostDoc as o, byCreatedAt as r, isExpired as s, REMOTE_HOST_PROTOCOL_VERSION as t, isRunnerHealth as u };
|
|
81
|
+
|
|
82
|
+
//# sourceMappingURL=remote-host-Dba4lF3Z.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"remote-host-Dba4lF3Z.js","names":[],"sources":["../src/remote-host/health.ts","../src/remote-host/index.ts"],"sourcesContent":["// Health of the remote-host command channel, as reported by the resilient runner\n// and rendered by a host's toolbar control. Browser-safe on purpose: the client\n// narrows the parsed HTTP payload with the same guard the server writes it from,\n// so the two sides cannot drift on the state names.\n//\n// online — the Firestore subscription is up; the phone can reach this host\n// reconnecting — it died and is being re-subscribed with backoff (self-healing)\n// offline — re-subscribing stopped helping, or nothing is connected at all;\n// recovering needs a re-auth from the browser's parked session\n//\n// Deliberately no UI wording here — how a state reads to a user is each host's\n// i18n, and core owning it would make the shared package a translation authority.\nexport const RUNNER_HEALTH_STATES = [\"online\", \"reconnecting\", \"offline\"] as const;\nexport type RunnerHealthState = (typeof RUNNER_HEALTH_STATES)[number];\n\nexport interface RunnerHealth {\n state: RunnerHealthState;\n /** Last channel error seen, for the popover and the log. Null before the first one. */\n lastError: string | null;\n /** ms epoch of the last state change, so the UI can say how long it has been down. */\n changedAt: number;\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> => typeof value === \"object\" && value !== null;\n\nexport const isRunnerHealthState = (value: unknown): value is RunnerHealthState => RUNNER_HEALTH_STATES.some((state) => state === value);\n\n/** Narrows a parsed HTTP payload. The client renders whatever this accepts, so a\n * half-shaped health has to read as \"no health reported\" rather than as a state. */\nexport const isRunnerHealth = (value: unknown): value is RunnerHealth =>\n isRecord(value) &&\n isRunnerHealthState(value.state) &&\n (value.lastError === null || typeof value.lastError === \"string\") &&\n typeof value.changedAt === \"number\";\n","// Remote-host command-channel protocol — the browser-safe contract shared by a\n// host (MulmoClaude, MulmoTerminal) and the remote/mobile client (mulmoserver).\n//\n// A host signs in to Firebase as the user, listens to that user's per-host\n// command queue in Firestore, runs a handler, and writes the result back; the\n// remote writes commands and reads results via a real-time listener. This module\n// owns the wire types + the Firestore path helpers. It is the single source of\n// truth so the host runner and the client never drift on the protocol.\n//\n// Ported from ../mulmoserver/src/firestore/commandChannel.ts and the per-host\n// copy that lived in MulmoClaude's server/remoteHost/. The one change vs. those\n// copies: the path helpers take the `firestore` instance as a parameter (rather\n// than importing a module-level singleton) so a single extracted module serves\n// every host's own Firebase init. The hostId is host-specific (\"mulmoclaude\",\n// \"mulmoterminal\") and is supplied by each host — there is no discovery.\nimport { CollectionReference, DocumentData, DocumentReference, Firestore, collection, doc } from \"firebase/firestore\";\nimport { isRecord } from \"@mulmoclaude/common\";\n\n// JSON payloads carried by the command channel. Explicit JSON types keep the\n// channel typed without resorting to any/unknown.\nexport type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };\nexport type JsonObject = Record<string, JsonValue>;\n\n/** Structural JSON view of `T`, recursively.\n *\n * TypeScript gives an implicit index signature to type aliases and mapped\n * types but NOT to interfaces, so a payload assembled from domain interfaces\n * (`Shortcut`, `FeedSummary`, …) cannot satisfy `Record<string, JsonValue>`\n * structurally — even though it is plain JSON at runtime. Mapping over `T`\n * reconstructs it as an anonymous type, which does get that index signature.\n *\n * Recursive on purpose: a top-level-only map would still leave nested\n * interfaces (`{ shortcuts: Shortcut[] }`) unassignable, which is the case\n * every handler here actually has. */\n// The function branch must come BEFORE the object branch: a function IS an\n// object to TypeScript, so without it a function maps to `{}` and sails\n// through — the helper would accept a payload that serialises to nothing.\n// Verified: `toJsonObject({ callback: () => undefined })` compiled clean until\n// this branch existed (CodeRabbit, #2596).\nexport type Jsonify<T> = T extends JsonValue\n ? T\n : T extends (...args: never[]) => unknown\n ? never\n : T extends (infer U)[]\n ? Jsonify<U>[]\n : T extends object\n ? { [K in keyof T]: Jsonify<T[K]> }\n : never;\n\n/** Widen a JSON-shaped handler payload to the channel's `JsonObject`.\n *\n * Exists so the `Jsonify` reasoning above lives in ONE place. Before this,\n * eight remote-host handlers each carried their own `as unknown as JsonObject`\n * with the justification re-argued in eight slightly different comments —\n * which is how a rule stops being reviewable. */\nexport const toJsonObject = <T extends object>(payload: Jsonify<T>): JsonObject => payload as JsonObject;\n\nconst describeNonJson = (value: unknown): string => {\n if (typeof value === \"number\") return String(value);\n if (typeof value === \"object\") return \"a non-plain object\";\n return `a ${typeof value}`;\n};\n\n/** Anything carrying its own JSON form — `Date` above all — must be asked for\n * it rather than walked, because walking a `Date`'s own enumerable keys finds\n * none and flattens the timestamp to `{}`. This is the step `JSON.stringify`\n * performs before it recurses, and the channel used to get it for free. */\nconst hasToJson = (value: object): value is { toJSON: () => unknown } => \"toJSON\" in value && typeof value.toJSON === \"function\";\n\nconst jsonRepresentationOf = (value: object): unknown => (hasToJson(value) ? value.toJSON() : value);\n\n/** Rebuild `value` as JSON, or throw naming the property that cannot be. */\nfunction toJsonValue(value: unknown, path: string): JsonValue {\n if (Array.isArray(value)) return toJsonItems(value, path);\n if (isRecord(value)) {\n const represented = jsonRepresentationOf(value);\n if (represented !== value) return toJsonValue(represented, path);\n return toJsonEntries(value, path);\n }\n return toJsonScalar(value, path);\n}\n\n/** JSON's four scalar forms. Anything else — a function, a class instance, a\n * non-finite number — is what the channel cannot carry. */\nfunction toJsonScalar(value: unknown, path: string): JsonValue {\n if (value === null || typeof value === \"string\" || typeof value === \"boolean\") return value;\n if (typeof value === \"number\" && Number.isFinite(value)) return value;\n throw new Error(`${path} is ${describeNonJson(value)}, which JSON cannot represent`);\n}\n\nfunction toJsonItems(items: unknown[], path: string): JsonValue[] {\n // An absent element becomes `null`, matching `JSON.stringify` — an array has\n // to keep its length, so a hole cannot simply be dropped the way a key is.\n // `Array.from` rather than `map`, which SKIPS holes and would leave them in\n // the result: `JSON.stringify` renders a hole as null and hides that, but\n // `1 in arr` / `Object.keys` / `forEach` all still see the gap.\n return Array.from(items, (entry, index) => (entry === undefined ? null : toJsonValue(entry, `${path}[${index}]`)));\n}\n\nfunction toJsonEntries(record: Record<string, unknown>, path: string): JsonObject {\n const usable = Object.entries(record).filter(([, value]) => value !== undefined);\n return Object.fromEntries(usable.map(([key, value]) => [key, toJsonValue(value, `${path}.${key}`)]));\n}\n\n/** Runtime counterpart to `toJsonObject`, for payloads whose values are typed\n * `unknown` — a collection record, a projected view row — so no amount of\n * mapped-type work can PROVE them JSON.\n *\n * Walks the payload and rebuilds it from the values it actually inspected, so\n * the returned `JsonObject` is earned rather than asserted. Absent (`undefined`)\n * properties are dropped exactly as `JSON.stringify` drops them; anything the\n * channel could not carry — a function, a class instance, `NaN` — throws\n * naming its path, instead of reaching Firestore as a silently mangled write. */\nexport const coerceJsonObject = (payload: Record<string, unknown>): JsonObject => toJsonEntries(payload, \"payload\");\n\n// A channel routes commands to one specific host. Both sides agree on a\n// hardcoded hostId per use case (e.g. \"mulmoclaude\", \"mulmoterminal\"); there is\n// no discovery — the remote and host just share the id.\nexport interface Channel {\n uid: string;\n hostId: string;\n}\n\nexport type CommandStatus = \"queued\" | \"processing\" | \"done\" | \"error\";\n\nexport interface CommandError {\n code: string;\n message: string;\n}\n\n// One document in a channel's commands subcollection is one API-call-like\n// request. The remote (mobile) writes method/params; the host writes\n// result/error/status.\nexport interface Command {\n method: string;\n params: JsonObject;\n status: CommandStatus;\n result: JsonValue;\n error: CommandError | null;\n createdBy: \"remote\" | \"host\";\n // Offline-queue fields (all optional; absent ⇒ pre-offline-queue behaviour, so\n // this is backward-compatible with every deployed client). Epoch-millisecond\n // NUMBERS set by the remote at enqueue time — deliberately plain numbers, not\n // Firestore Timestamps, so `isExpired` / `byCreatedAt` stay pure + browser-safe\n // and unit-testable without a Firestore fake. Clock skew over a multi-day expiry\n // window is immaterial. See plans/done/feat-remote-offline-queue.md.\n createdAt?: number; // enqueue time — age/display + best-effort dispatch bias (NOT a strict order guarantee; chat is async)\n expiresAt?: number; // deadline; past it the host deletes the command + its staged attachments\n queuedOffline?: boolean; // emitted while the host was offline (gates the remote's attachment rollback)\n}\n\n// A command is expired once `now` reaches its remote-set deadline. Absent\n// `expiresAt` ⇒ it never expires (pre-offline-queue commands). Pure with an\n// injected `now` for deterministic tests; the runner passes `Date.now()`.\nexport const isExpired = (command: Pick<Command, \"expiresAt\">, now: number): boolean => typeof command.expiresAt === \"number\" && now >= command.expiresAt;\n\n// Best-effort dispatch bias for a drained batch: oldest enqueue first. This is\n// NOT an ordering guarantee — commands run concurrently and may complete out of\n// order (chat is asynchronous, by design); it only nudges which one starts first.\n// A command with no `createdAt` sorts as oldest (0) so it is never starved.\nexport const byCreatedAt = (left: Pick<Command, \"createdAt\">, right: Pick<Command, \"createdAt\">): number => (left.createdAt ?? 0) - (right.createdAt ?? 0);\n\nexport type CommandHandler = (params: JsonObject) => JsonValue | Promise<JsonValue>;\nexport type CommandHandlers = Record<string, CommandHandler>;\n\n// Bumped when the command-channel wire protocol changes in a way the remote must\n// gate on. Advertised in the presence doc so the remote can check compatibility\n// before issuing commands.\n//\n// v2: offline queueing. The host honours `expiresAt` (deletes an expired command\n// + its staged attachments instead of spawning a stale chat). A remote MUST see\n// protocolVersion >= 2 before queueing a startChat while the host is offline —\n// a v1 host silently ignores `expiresAt`, so a queued chat would spawn stale on\n// reconnect with its uploads never cleaned up.\nexport const REMOTE_HOST_PROTOCOL_VERSION = 2;\n\n// The presence doc's payload: online flag + a capability advertisement. Written\n// by the host on every heartbeat; the remote reads it from the presence listener\n// it already runs (no extra round trip, known the instant the host is online).\n// Browser-safe so the mobile client compiles against the same shape.\n// `updatedAt` (a Firestore serverTimestamp) is added by the runner at write time\n// and is intentionally not part of this capability contract.\nexport interface HostPresence {\n online: boolean;\n hostId: string;\n protocolVersion: number;\n // Method names the host serves — the keys of the live handler table.\n capabilities: string[];\n}\n\n// Build the presence payload from the live handler table. Capabilities are\n// `Object.keys(handlers)` so registering a handler is the ONLY step needed to\n// advertise it — there is no second list to keep in sync.\nexport const buildHostPresence = (channel: Channel, handlers: CommandHandlers, online: boolean): HostPresence => ({\n online,\n hostId: channel.hostId,\n protocolVersion: REMOTE_HOST_PROTOCOL_VERSION,\n capabilities: Object.keys(handlers),\n});\n\n// Per-host command queue: users/{uid}/hosts/{hostId}/commands.\nexport const commandsCollection = (firestore: Firestore, channel: Channel): CollectionReference<DocumentData> =>\n collection(firestore, \"users\", channel.uid, \"hosts\", channel.hostId, \"commands\");\n\n// Presence doc for a host: users/{uid}/hosts/{hostId}. The host heartbeats\n// { online, updatedAt } here; the remote reads it to know if the host is up.\nexport const hostDoc = (firestore: Firestore, channel: Channel): DocumentReference<DocumentData> =>\n doc(firestore, \"users\", channel.uid, \"hosts\", channel.hostId);\n\n// Channel health as the resilient runner reports it. Browser-safe alongside the\n// wire types because the control that renders it runs in the client.\nexport { RUNNER_HEALTH_STATES, isRunnerHealth, isRunnerHealthState } from \"./health.js\";\nexport type { RunnerHealth, RunnerHealthState } from \"./health.js\";\n"],"mappings":";;;AAYA,IAAa,uBAAuB;CAAC;CAAU;CAAgB;AAAS;AAWxE,IAAM,YAAY,UAAqD,OAAO,UAAU,YAAY,UAAU;AAE9G,IAAa,uBAAuB,UAA+C,qBAAqB,MAAM,UAAU,UAAU,KAAK;;;AAIvI,IAAa,kBAAkB,UAC7B,SAAS,KAAK,KACd,oBAAoB,MAAM,KAAK,MAC9B,MAAM,cAAc,QAAQ,OAAO,MAAM,cAAc,aACxD,OAAO,MAAM,cAAc;;;;;;;;;ACsB7B,IAAa,gBAAkC,YAAoC;AAEnF,IAAM,mBAAmB,UAA2B;CAClD,IAAI,OAAO,UAAU,UAAU,OAAO,OAAO,KAAK;CAClD,IAAI,OAAO,UAAU,UAAU,OAAO;CACtC,OAAO,KAAK,OAAO;AACrB;;;;;AAMA,IAAM,aAAa,UAAsD,YAAY,SAAS,OAAO,MAAM,WAAW;AAEtH,IAAM,wBAAwB,UAA4B,UAAU,KAAK,IAAI,MAAM,OAAO,IAAI;;AAG9F,SAAS,YAAY,OAAgB,MAAyB;CAC5D,IAAI,MAAM,QAAQ,KAAK,GAAG,OAAO,YAAY,OAAO,IAAI;CACxD,IAAI,WAAS,KAAK,GAAG;EACnB,MAAM,cAAc,qBAAqB,KAAK;EAC9C,IAAI,gBAAgB,OAAO,OAAO,YAAY,aAAa,IAAI;EAC/D,OAAO,cAAc,OAAO,IAAI;CAClC;CACA,OAAO,aAAa,OAAO,IAAI;AACjC;;;AAIA,SAAS,aAAa,OAAgB,MAAyB;CAC7D,IAAI,UAAU,QAAQ,OAAO,UAAU,YAAY,OAAO,UAAU,WAAW,OAAO;CACtF,IAAI,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK,GAAG,OAAO;CAChE,MAAM,IAAI,MAAM,GAAG,KAAK,MAAM,gBAAgB,KAAK,EAAE,8BAA8B;AACrF;AAEA,SAAS,YAAY,OAAkB,MAA2B;CAMhE,OAAO,MAAM,KAAK,QAAQ,OAAO,UAAW,UAAU,KAAA,IAAY,OAAO,YAAY,OAAO,GAAG,KAAK,GAAG,MAAM,EAAE,CAAE;AACnH;AAEA,SAAS,cAAc,QAAiC,MAA0B;CAChF,MAAM,SAAS,OAAO,QAAQ,MAAM,CAAC,CAAC,QAAQ,GAAG,WAAW,UAAU,KAAA,CAAS;CAC/E,OAAO,OAAO,YAAY,OAAO,KAAK,CAAC,KAAK,WAAW,CAAC,KAAK,YAAY,OAAO,GAAG,KAAK,GAAG,KAAK,CAAC,CAAC,CAAC;AACrG;;;;;;;;;;AAWA,IAAa,oBAAoB,YAAiD,cAAc,SAAS,SAAS;AAyClH,IAAa,aAAa,SAAqC,QAAyB,OAAO,QAAQ,cAAc,YAAY,OAAO,QAAQ;AAMhJ,IAAa,eAAe,MAAkC,WAA+C,KAAK,aAAa,MAAM,MAAM,aAAa;AAcxJ,IAAa,+BAA+B;AAmB5C,IAAa,qBAAqB,SAAkB,UAA2B,YAAmC;CAChH;CACA,QAAQ,QAAQ;CAChB,iBAAA;CACA,cAAc,OAAO,KAAK,QAAQ;AACpC;AAGA,IAAa,sBAAsB,WAAsB,YACvD,WAAW,WAAW,SAAS,QAAQ,KAAK,SAAS,QAAQ,QAAQ,UAAU;AAIjF,IAAa,WAAW,WAAsB,YAC5C,IAAI,WAAW,SAAS,QAAQ,KAAK,SAAS,QAAQ,MAAM"}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const
|
|
2
|
+
const require_dist = require("../dist-CsgSfWwR.cjs");
|
|
3
|
+
const require_project = require("../project-C1ep9pvo.cjs");
|
|
3
4
|
//#region src/remote-view/index.ts
|
|
4
5
|
/** Bump when the bootstrap/message contract changes shape; the bootstrap
|
|
5
6
|
* exposes it as `__MC_VIEW.protocol` so a parent can refuse a stale view.
|
|
@@ -180,11 +181,12 @@ function normalizeMutate(data) {
|
|
|
180
181
|
id: itemId
|
|
181
182
|
};
|
|
182
183
|
if (data.op === "update") {
|
|
183
|
-
|
|
184
|
+
const { patch } = data;
|
|
185
|
+
if (!require_dist.isRecord(patch)) return null;
|
|
184
186
|
return {
|
|
185
187
|
op: "update",
|
|
186
188
|
id: itemId,
|
|
187
|
-
patch
|
|
189
|
+
patch
|
|
188
190
|
};
|
|
189
191
|
}
|
|
190
192
|
return null;
|
|
@@ -256,7 +258,7 @@ async function answerMutate(requestId, msg, handlers, reply) {
|
|
|
256
258
|
* was a remote-view request for this slug (callers ignore everything else).
|
|
257
259
|
*/
|
|
258
260
|
async function handleRemoteViewMessage(data, handlers, reply) {
|
|
259
|
-
if (
|
|
261
|
+
if (!require_dist.isRecord(data)) return false;
|
|
260
262
|
const msg = data;
|
|
261
263
|
if (msg.slug !== handlers.slug) return false;
|
|
262
264
|
if (msg.type === REMOTE_VIEW_MESSAGES.startChat) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.cjs","names":[],"sources":["../../src/remote-view/index.ts"],"sourcesContent":["// The remote custom-view contract (phase 3 — plans/done/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// BACKWARD COMPATIBILITY — this bridge is a frozen public contract. Remote\n// views are LLM-authored HTML files persisted in users' workspaces\n// (`data/skills/*/views/*.html`), written against\n// `packages/core/assets/helps/custom-view-remote.md`; they cannot be\n// migrated centrally and must keep working across host upgrades and any\n// storage-virtualization work underneath. Evolve only by backward-compatible\n// supersets, the way protocol v2 added the mutate pair: bump\n// `REMOTE_VIEW_PROTOCOL`, add new message types / optional fields — never\n// repurpose an existing message type, change the `getItems` page shape\n// (`{ items, total, offset, limit }`), or tighten limits a shipped view may\n// already rely on.\n\nimport { projectRecordFields } from \"../collection/core/project\";\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/** Hard cap on ONE `getItems` page (phase 5 — plans/done/feat-remote-view-images.md).\n * Same 1 MiB command-document envelope as the srcdoc: when a view inlines image\n * fields as `data:` URLs, the host stops inlining once the serialized page would\n * exceed this, leaving the remaining image fields as their original path (which\n * the view renders as a placeholder). Guards the doc-write from ever failing. */\nexport const REMOTE_VIEW_ITEMS_MAX_BYTES = 900_000;\n\n/** Default longest-edge (px) a remote view's inlined image thumbnail is\n * downscaled to; a view may override via `imageMaxEdge`. */\nexport const DEFAULT_IMAGE_MAX_EDGE = 512;\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/** Read a channel/postMessage identifier (a slug, a view id) as a string.\n * Params arrive as untyped JSON, so a caller can send anything; `String(...)`\n * turned an object into the literal \"[object Object]\", which then travelled on\n * as if the caller had asked for a collection by that name — surfacing as\n * `collection '[object Object]' not found` rather than a bad-request. A value\n * with no string form is simply absent. */\nexport const readIdParam = (value: unknown): string => (typeof value === \"string\" ? value : \"\");\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/** Clamp an `imageMaxEdge` (arrives as untyped schema/JSON) to [64, 1024];\n * default 512. Keeps a runaway edge from defeating the thumbnail's purpose. */\nexport const clampImageMaxEdge = (value: unknown): number => {\n const num = toInt(value);\n if (num === null || num <= 0) return DEFAULT_IMAGE_MAX_EDGE;\n return Math.min(Math.max(num, 64), 1024);\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`; an EMPTY `fields` array is\n * also a no-op here (frozen bridge behavior — the shared helper would\n * project down to the primary key alone). */\nexport function projectItems(items: RemoteViewItem[], fields: string[] | undefined, primaryKey: string): RemoteViewItem[] {\n if (!fields || fields.length === 0) return items;\n return projectRecordFields(items, fields, primaryKey);\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\n/** Validate + dispatch a `mc-remote-mutate` payload, replying on every path\n * (malformed request, read-only parent, handler success/throw). Split out of\n * `handleRemoteViewMessage` so that function stays under the 20-line limit. */\nasync function answerMutate(\n requestId: string,\n msg: { op?: unknown; id?: unknown; patch?: unknown },\n handlers: RemoteViewBridgeHandlers,\n reply: RemoteViewReply,\n): Promise<void> {\n const request = normalizeMutate(msg);\n if (!request) {\n reply({ type: REMOTE_VIEW_MESSAGES.mutateResult, requestId, ok: false, error: \"invalid mutate request\" });\n return;\n }\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 await answerMutate(msg.requestId, msg, 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":";;;;;;;AA4BA,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;;;;;;AAOrC,IAAa,8BAA8B;;;AAI3C,IAAa,yBAAyB;;;AAItC,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;;;;;;;AAQpF,IAAa,eAAe,UAA4B,OAAO,UAAU,WAAW,QAAQ;;AAG5F,IAAa,cAAc,UAA2B;CACpD,MAAM,MAAM,MAAM,KAAK;CACvB,IAAI,QAAQ,QAAQ,OAAO,GAAG,OAAA;CAC9B,OAAO,KAAK,IAAI,KAAA,GAAmB;AACrC;;;AAIA,IAAa,qBAAqB,UAA2B;CAC3D,MAAM,MAAM,MAAM,KAAK;CACvB,IAAI,QAAQ,QAAQ,OAAO,GAAG,OAAA;CAC9B,OAAO,KAAK,IAAI,KAAK,IAAI,KAAK,EAAE,GAAG,IAAI;AACzC;;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;;;;;;;AA6BA,SAAgB,aAAa,OAAyB,QAA8B,YAAsC;CACxH,IAAI,CAAC,UAAU,OAAO,WAAW,GAAG,OAAO;CAC3C,OAAO,gBAAA,oBAAoB,OAAO,QAAQ,UAAU;AACtD;;;;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;;;;AAKA,eAAe,aACb,WACA,KACA,UACA,OACe;CACf,MAAM,UAAU,gBAAgB,GAAG;CACnC,IAAI,CAAC,SAAS;EACZ,MAAM;GAAE,MAAM,qBAAqB;GAAc;GAAW,IAAI;GAAO,OAAO;EAAyB,CAAC;EACxG;CACF;CACA,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,aAAa,IAAI,WAAW,KAAK,UAAU,KAAK;EACtD,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"}
|
|
1
|
+
{"version":3,"file":"index.cjs","names":[],"sources":["../../src/remote-view/index.ts"],"sourcesContent":["// The remote custom-view contract (phase 3 — plans/done/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// BACKWARD COMPATIBILITY — this bridge is a frozen public contract. Remote\n// views are LLM-authored HTML files persisted in users' workspaces\n// (`data/skills/*/views/*.html`), written against\n// `packages/core/assets/helps/custom-view-remote.md`; they cannot be\n// migrated centrally and must keep working across host upgrades and any\n// storage-virtualization work underneath. Evolve only by backward-compatible\n// supersets, the way protocol v2 added the mutate pair: bump\n// `REMOTE_VIEW_PROTOCOL`, add new message types / optional fields — never\n// repurpose an existing message type, change the `getItems` page shape\n// (`{ items, total, offset, limit }`), or tighten limits a shipped view may\n// already rely on.\n\nimport { isRecord } from \"@mulmoclaude/common\";\nimport { projectRecordFields } from \"../collection/core/project\";\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/** Hard cap on ONE `getItems` page (phase 5 — plans/done/feat-remote-view-images.md).\n * Same 1 MiB command-document envelope as the srcdoc: when a view inlines image\n * fields as `data:` URLs, the host stops inlining once the serialized page would\n * exceed this, leaving the remaining image fields as their original path (which\n * the view renders as a placeholder). Guards the doc-write from ever failing. */\nexport const REMOTE_VIEW_ITEMS_MAX_BYTES = 900_000;\n\n/** Default longest-edge (px) a remote view's inlined image thumbnail is\n * downscaled to; a view may override via `imageMaxEdge`. */\nexport const DEFAULT_IMAGE_MAX_EDGE = 512;\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/** Read a channel/postMessage identifier (a slug, a view id) as a string.\n * Params arrive as untyped JSON, so a caller can send anything; `String(...)`\n * turned an object into the literal \"[object Object]\", which then travelled on\n * as if the caller had asked for a collection by that name — surfacing as\n * `collection '[object Object]' not found` rather than a bad-request. A value\n * with no string form is simply absent. */\nexport const readIdParam = (value: unknown): string => (typeof value === \"string\" ? value : \"\");\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/** Clamp an `imageMaxEdge` (arrives as untyped schema/JSON) to [64, 1024];\n * default 512. Keeps a runaway edge from defeating the thumbnail's purpose. */\nexport const clampImageMaxEdge = (value: unknown): number => {\n const num = toInt(value);\n if (num === null || num <= 0) return DEFAULT_IMAGE_MAX_EDGE;\n return Math.min(Math.max(num, 64), 1024);\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[] | undefined;\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`; an EMPTY `fields` array is\n * also a no-op here (frozen bridge behavior — the shared helper would\n * project down to the primary key alone). */\nexport function projectItems(items: RemoteViewItem[], fields: string[] | undefined, primaryKey: string): RemoteViewItem[] {\n if (!fields || fields.length === 0) return items;\n return projectRecordFields(items, fields, primaryKey);\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 const { patch } = data;\n if (!isRecord(patch)) return null;\n return { op: \"update\", id: itemId, patch };\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\n/** Validate + dispatch a `mc-remote-mutate` payload, replying on every path\n * (malformed request, read-only parent, handler success/throw). Split out of\n * `handleRemoteViewMessage` so that function stays under the 20-line limit. */\nasync function answerMutate(\n requestId: string,\n msg: { op?: unknown; id?: unknown; patch?: unknown },\n handlers: RemoteViewBridgeHandlers,\n reply: RemoteViewReply,\n): Promise<void> {\n const request = normalizeMutate(msg);\n if (!request) {\n reply({ type: REMOTE_VIEW_MESSAGES.mutateResult, requestId, ok: false, error: \"invalid mutate request\" });\n return;\n }\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 (!isRecord(data)) return false;\n const msg = data;\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 await answerMutate(msg.requestId, msg, 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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"}
|
|
@@ -68,7 +68,7 @@ export interface RemoteViewPage {
|
|
|
68
68
|
export interface RemoteViewPageRequest {
|
|
69
69
|
offset: number;
|
|
70
70
|
limit: number;
|
|
71
|
-
fields?: string[];
|
|
71
|
+
fields?: string[] | undefined;
|
|
72
72
|
}
|
|
73
73
|
/** Keep only `fields` (+ always the primary key) on each record. Parents apply
|
|
74
74
|
* this uniformly — the desktop preview via `pageFromItems`, the phone parent
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { s as isRecord } from "../dist-D8zokgGo.js";
|
|
2
|
+
import { n as projectRecordFields } from "../project-B0j_Vf_Q.js";
|
|
2
3
|
//#region src/remote-view/index.ts
|
|
3
4
|
/** Bump when the bootstrap/message contract changes shape; the bootstrap
|
|
4
5
|
* exposes it as `__MC_VIEW.protocol` so a parent can refuse a stale view.
|
|
@@ -179,11 +180,12 @@ function normalizeMutate(data) {
|
|
|
179
180
|
id: itemId
|
|
180
181
|
};
|
|
181
182
|
if (data.op === "update") {
|
|
182
|
-
|
|
183
|
+
const { patch } = data;
|
|
184
|
+
if (!isRecord(patch)) return null;
|
|
183
185
|
return {
|
|
184
186
|
op: "update",
|
|
185
187
|
id: itemId,
|
|
186
|
-
patch
|
|
188
|
+
patch
|
|
187
189
|
};
|
|
188
190
|
}
|
|
189
191
|
return null;
|
|
@@ -255,7 +257,7 @@ async function answerMutate(requestId, msg, handlers, reply) {
|
|
|
255
257
|
* was a remote-view request for this slug (callers ignore everything else).
|
|
256
258
|
*/
|
|
257
259
|
async function handleRemoteViewMessage(data, handlers, reply) {
|
|
258
|
-
if (
|
|
260
|
+
if (!isRecord(data)) return false;
|
|
259
261
|
const msg = data;
|
|
260
262
|
if (msg.slug !== handlers.slug) return false;
|
|
261
263
|
if (msg.type === REMOTE_VIEW_MESSAGES.startChat) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../../src/remote-view/index.ts"],"sourcesContent":["// The remote custom-view contract (phase 3 — plans/done/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// BACKWARD COMPATIBILITY — this bridge is a frozen public contract. Remote\n// views are LLM-authored HTML files persisted in users' workspaces\n// (`data/skills/*/views/*.html`), written against\n// `packages/core/assets/helps/custom-view-remote.md`; they cannot be\n// migrated centrally and must keep working across host upgrades and any\n// storage-virtualization work underneath. Evolve only by backward-compatible\n// supersets, the way protocol v2 added the mutate pair: bump\n// `REMOTE_VIEW_PROTOCOL`, add new message types / optional fields — never\n// repurpose an existing message type, change the `getItems` page shape\n// (`{ items, total, offset, limit }`), or tighten limits a shipped view may\n// already rely on.\n\nimport { projectRecordFields } from \"../collection/core/project\";\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/** Hard cap on ONE `getItems` page (phase 5 — plans/done/feat-remote-view-images.md).\n * Same 1 MiB command-document envelope as the srcdoc: when a view inlines image\n * fields as `data:` URLs, the host stops inlining once the serialized page would\n * exceed this, leaving the remaining image fields as their original path (which\n * the view renders as a placeholder). Guards the doc-write from ever failing. */\nexport const REMOTE_VIEW_ITEMS_MAX_BYTES = 900_000;\n\n/** Default longest-edge (px) a remote view's inlined image thumbnail is\n * downscaled to; a view may override via `imageMaxEdge`. */\nexport const DEFAULT_IMAGE_MAX_EDGE = 512;\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/** Read a channel/postMessage identifier (a slug, a view id) as a string.\n * Params arrive as untyped JSON, so a caller can send anything; `String(...)`\n * turned an object into the literal \"[object Object]\", which then travelled on\n * as if the caller had asked for a collection by that name — surfacing as\n * `collection '[object Object]' not found` rather than a bad-request. A value\n * with no string form is simply absent. */\nexport const readIdParam = (value: unknown): string => (typeof value === \"string\" ? value : \"\");\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/** Clamp an `imageMaxEdge` (arrives as untyped schema/JSON) to [64, 1024];\n * default 512. Keeps a runaway edge from defeating the thumbnail's purpose. */\nexport const clampImageMaxEdge = (value: unknown): number => {\n const num = toInt(value);\n if (num === null || num <= 0) return DEFAULT_IMAGE_MAX_EDGE;\n return Math.min(Math.max(num, 64), 1024);\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`; an EMPTY `fields` array is\n * also a no-op here (frozen bridge behavior — the shared helper would\n * project down to the primary key alone). */\nexport function projectItems(items: RemoteViewItem[], fields: string[] | undefined, primaryKey: string): RemoteViewItem[] {\n if (!fields || fields.length === 0) return items;\n return projectRecordFields(items, fields, primaryKey);\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\n/** Validate + dispatch a `mc-remote-mutate` payload, replying on every path\n * (malformed request, read-only parent, handler success/throw). Split out of\n * `handleRemoteViewMessage` so that function stays under the 20-line limit. */\nasync function answerMutate(\n requestId: string,\n msg: { op?: unknown; id?: unknown; patch?: unknown },\n handlers: RemoteViewBridgeHandlers,\n reply: RemoteViewReply,\n): Promise<void> {\n const request = normalizeMutate(msg);\n if (!request) {\n reply({ type: REMOTE_VIEW_MESSAGES.mutateResult, requestId, ok: false, error: \"invalid mutate request\" });\n return;\n }\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 await answerMutate(msg.requestId, msg, 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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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../src/remote-view/index.ts"],"sourcesContent":["// The remote custom-view contract (phase 3 — plans/done/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// BACKWARD COMPATIBILITY — this bridge is a frozen public contract. Remote\n// views are LLM-authored HTML files persisted in users' workspaces\n// (`data/skills/*/views/*.html`), written against\n// `packages/core/assets/helps/custom-view-remote.md`; they cannot be\n// migrated centrally and must keep working across host upgrades and any\n// storage-virtualization work underneath. Evolve only by backward-compatible\n// supersets, the way protocol v2 added the mutate pair: bump\n// `REMOTE_VIEW_PROTOCOL`, add new message types / optional fields — never\n// repurpose an existing message type, change the `getItems` page shape\n// (`{ items, total, offset, limit }`), or tighten limits a shipped view may\n// already rely on.\n\nimport { isRecord } from \"@mulmoclaude/common\";\nimport { projectRecordFields } from \"../collection/core/project\";\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/** Hard cap on ONE `getItems` page (phase 5 — plans/done/feat-remote-view-images.md).\n * Same 1 MiB command-document envelope as the srcdoc: when a view inlines image\n * fields as `data:` URLs, the host stops inlining once the serialized page would\n * exceed this, leaving the remaining image fields as their original path (which\n * the view renders as a placeholder). Guards the doc-write from ever failing. */\nexport const REMOTE_VIEW_ITEMS_MAX_BYTES = 900_000;\n\n/** Default longest-edge (px) a remote view's inlined image thumbnail is\n * downscaled to; a view may override via `imageMaxEdge`. */\nexport const DEFAULT_IMAGE_MAX_EDGE = 512;\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/** Read a channel/postMessage identifier (a slug, a view id) as a string.\n * Params arrive as untyped JSON, so a caller can send anything; `String(...)`\n * turned an object into the literal \"[object Object]\", which then travelled on\n * as if the caller had asked for a collection by that name — surfacing as\n * `collection '[object Object]' not found` rather than a bad-request. A value\n * with no string form is simply absent. */\nexport const readIdParam = (value: unknown): string => (typeof value === \"string\" ? value : \"\");\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/** Clamp an `imageMaxEdge` (arrives as untyped schema/JSON) to [64, 1024];\n * default 512. Keeps a runaway edge from defeating the thumbnail's purpose. */\nexport const clampImageMaxEdge = (value: unknown): number => {\n const num = toInt(value);\n if (num === null || num <= 0) return DEFAULT_IMAGE_MAX_EDGE;\n return Math.min(Math.max(num, 64), 1024);\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[] | undefined;\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`; an EMPTY `fields` array is\n * also a no-op here (frozen bridge behavior — the shared helper would\n * project down to the primary key alone). */\nexport function projectItems(items: RemoteViewItem[], fields: string[] | undefined, primaryKey: string): RemoteViewItem[] {\n if (!fields || fields.length === 0) return items;\n return projectRecordFields(items, fields, primaryKey);\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 const { patch } = data;\n if (!isRecord(patch)) return null;\n return { op: \"update\", id: itemId, patch };\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\n/** Validate + dispatch a `mc-remote-mutate` payload, replying on every path\n * (malformed request, read-only parent, handler success/throw). Split out of\n * `handleRemoteViewMessage` so that function stays under the 20-line limit. */\nasync function answerMutate(\n requestId: string,\n msg: { op?: unknown; id?: unknown; patch?: unknown },\n handlers: RemoteViewBridgeHandlers,\n reply: RemoteViewReply,\n): Promise<void> {\n const request = normalizeMutate(msg);\n if (!request) {\n reply({ type: REMOTE_VIEW_MESSAGES.mutateResult, requestId, ok: false, error: \"invalid mutate request\" });\n return;\n }\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 (!isRecord(data)) return false;\n const msg = data;\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 await answerMutate(msg.requestId, msg, 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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"}
|
|
@@ -31,9 +31,9 @@ export declare function initScheduler(taskManager: ITaskManager, tasks: SystemTa
|
|
|
31
31
|
export declare function applyScheduleOverride(taskId: string, schedule: SystemTaskDef["schedule"]): Promise<boolean>;
|
|
32
32
|
/** Query execution logs — used by API routes. */
|
|
33
33
|
export declare function getSchedulerLogs(opts: {
|
|
34
|
-
since?: string;
|
|
35
|
-
taskId?: string;
|
|
36
|
-
limit?: number;
|
|
34
|
+
since?: string | undefined;
|
|
35
|
+
taskId?: string | undefined;
|
|
36
|
+
limit?: number | undefined;
|
|
37
37
|
}): Promise<TaskLogEntry[]>;
|
|
38
38
|
/** Get all task states — used by API routes. */
|
|
39
39
|
export declare function getSchedulerTasks(): {
|
|
@@ -65,7 +65,7 @@ export declare function recordExternalRun(params: {
|
|
|
65
65
|
durationMs: number;
|
|
66
66
|
trigger: TaskTrigger;
|
|
67
67
|
errorMessage: string | null;
|
|
68
|
-
chatSessionId?: string;
|
|
68
|
+
chatSessionId?: string | undefined;
|
|
69
69
|
}): Promise<void>;
|
|
70
70
|
/** Test-only: clear config + in-memory state. */
|
|
71
71
|
export declare function resetSchedulerForTesting(): void;
|
package/dist/scheduler/index.cjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
2
|
const require_rolldown_runtime = require("../rolldown-runtime-D6vf50IK.cjs");
|
|
3
|
-
const require_dist = require("../dist-
|
|
3
|
+
const require_dist = require("../dist-CsgSfWwR.cjs");
|
|
4
4
|
let node_fs = require("node:fs");
|
|
5
5
|
let node_path = require("node:path");
|
|
6
6
|
node_path = require_rolldown_runtime.__toESM(node_path, 1);
|
|
@@ -29,13 +29,43 @@ function isDue(now, schedule, tickMs) {
|
|
|
29
29
|
return Math.floor(msSinceMidnight / tickMs) * tickMs % schedule.intervalMs === 0;
|
|
30
30
|
}
|
|
31
31
|
if (schedule.type === _receptron_task_scheduler.SCHEDULE_TYPES.daily) {
|
|
32
|
-
const
|
|
33
|
-
|
|
32
|
+
const targetMs = dailyTargetMs(schedule.time);
|
|
33
|
+
if (targetMs === null) return false;
|
|
34
34
|
const msSinceMidnight = now.getUTCHours() * ONE_HOUR_MS + now.getUTCMinutes() * ONE_MINUTE_MS + now.getUTCSeconds() * ONE_SECOND_MS$1;
|
|
35
35
|
return Math.floor(msSinceMidnight / tickMs) * tickMs === targetMs;
|
|
36
36
|
}
|
|
37
37
|
return false;
|
|
38
38
|
}
|
|
39
|
+
/** Milliseconds past UTC midnight for a `HH:MM` daily time, or null when the
|
|
40
|
+
* string isn't that shape. `TaskSchedule.time` is a bare `string` on a
|
|
41
|
+
* published package, so a host that skips its own validation must land on
|
|
42
|
+
* "never due" rather than on a garbage firing time. */
|
|
43
|
+
function dailyTargetMs(time) {
|
|
44
|
+
const [hours, minutes] = time.split(":").map(Number);
|
|
45
|
+
if (hours === void 0 || minutes === void 0) return null;
|
|
46
|
+
if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return null;
|
|
47
|
+
return hours * ONE_HOUR_MS + minutes * ONE_MINUTE_MS;
|
|
48
|
+
}
|
|
49
|
+
/** The `time` of a daily schedule this manager can never fire, or null when the
|
|
50
|
+
* schedule is fine. Returning the offending value (not a boolean) is what lets
|
|
51
|
+
* the caller name it in the log. */
|
|
52
|
+
function unfireableDailyTime(schedule) {
|
|
53
|
+
if (schedule.type !== _receptron_task_scheduler.SCHEDULE_TYPES.daily) return null;
|
|
54
|
+
return dailyTargetMs(schedule.time) === null ? schedule.time : null;
|
|
55
|
+
}
|
|
56
|
+
/** Say out loud that this schedule can never fire. The task is still accepted —
|
|
57
|
+
* `isDue` answers false forever, which is the safe outcome — but silence here
|
|
58
|
+
* is what made "the task I scheduled has never run once" a bug with no
|
|
59
|
+
* evidence anywhere to start from (#2765). Deliberately not a throw: a
|
|
60
|
+
* consumer whose task has been quietly dead would get a boot crash instead. */
|
|
61
|
+
function reportUnfireable(log, taskId, schedule) {
|
|
62
|
+
const time = unfireableDailyTime(schedule);
|
|
63
|
+
if (time === null) return;
|
|
64
|
+
log.error("daily time is not HH:MM — this task will never run", {
|
|
65
|
+
id: taskId,
|
|
66
|
+
time
|
|
67
|
+
});
|
|
68
|
+
}
|
|
39
69
|
/** Split the due tasks into those that may run immediately and those gated
|
|
40
70
|
* behind a `dependsOn` edge (resolved later in the same tick cycle). */
|
|
41
71
|
function collectDueTasks(currentTime, registry, tickMs) {
|
|
@@ -140,12 +170,14 @@ function createTaskManager(options) {
|
|
|
140
170
|
},
|
|
141
171
|
registerTask(def) {
|
|
142
172
|
if (registry.has(def.id)) throw new Error(`[task-manager] Task "${def.id}" is already registered`);
|
|
173
|
+
reportUnfireable(log, def.id, def.schedule);
|
|
143
174
|
registry.set(def.id, def);
|
|
144
175
|
log.info("registered", { id: def.id });
|
|
145
176
|
},
|
|
146
177
|
updateSchedule(taskId, schedule) {
|
|
147
178
|
const def = registry.get(taskId);
|
|
148
179
|
if (!def) return false;
|
|
180
|
+
reportUnfireable(log, taskId, schedule);
|
|
149
181
|
def.schedule = schedule;
|
|
150
182
|
log.info("schedule updated", { id: taskId });
|
|
151
183
|
return true;
|