@mulmoclaude/core 0.24.0 → 0.25.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.
Files changed (103) hide show
  1. package/assets/helps/collection-skills.md +51 -0
  2. package/assets/helps/custom-view.md +37 -3
  3. package/assets/helps/error-recovery.md +30 -0
  4. package/assets/helps/google-calendar-collection.md +79 -0
  5. package/assets/helps/index.md +1 -0
  6. package/dist/collection/core/project.d.ts +3 -0
  7. package/dist/collection/core/schema.d.ts +13 -1
  8. package/dist/collection/core/schemaZ.d.ts +45 -0
  9. package/dist/collection/index.cjs +18 -14
  10. package/dist/collection/index.cjs.map +1 -1
  11. package/dist/collection/index.d.ts +1 -0
  12. package/dist/collection/index.js +4 -2
  13. package/dist/collection/index.js.map +1 -1
  14. package/dist/collection/paths.cjs +5 -55
  15. package/dist/collection/paths.js +1 -51
  16. package/dist/collection/registry/server/index.cjs +20 -19
  17. package/dist/collection/registry/server/index.cjs.map +1 -1
  18. package/dist/collection/registry/server/index.js +3 -2
  19. package/dist/collection/registry/server/index.js.map +1 -1
  20. package/dist/collection/server/csvStore.d.ts +8 -1
  21. package/dist/collection/server/discoveredCollection.d.ts +4 -0
  22. package/dist/collection/server/discovery.d.ts +1 -0
  23. package/dist/collection/server/index.cjs +58 -54
  24. package/dist/collection/server/index.d.ts +1 -0
  25. package/dist/collection/server/index.js +5 -4
  26. package/dist/collection/server/queryRunner.d.ts +4 -0
  27. package/dist/collection/server/spawn.d.ts +2 -1
  28. package/dist/collection/server/sqliteStore.d.ts +14 -0
  29. package/dist/collection/server/store.d.ts +43 -8
  30. package/dist/collection/server/storePage.d.ts +32 -0
  31. package/dist/collection-watchers/index.cjs +131 -44
  32. package/dist/collection-watchers/index.cjs.map +1 -1
  33. package/dist/collection-watchers/index.d.ts +1 -1
  34. package/dist/collection-watchers/index.js +125 -39
  35. package/dist/collection-watchers/index.js.map +1 -1
  36. package/dist/collection-watchers/reconciler.d.ts +13 -11
  37. package/dist/collection-watchers/watcher.d.ts +4 -3
  38. package/dist/{server-BpEo0FOd.js → discovery-QUi_7aNG.js} +414 -1495
  39. package/dist/discovery-QUi_7aNG.js.map +1 -0
  40. package/dist/{server-CkysfLh4.cjs → discovery-cwtfyDxl.cjs} +463 -1658
  41. package/dist/discovery-cwtfyDxl.cjs.map +1 -0
  42. package/dist/feeds/index.cjs +4 -4
  43. package/dist/feeds/index.js +2 -2
  44. package/dist/feeds/server/index.cjs +35 -22
  45. package/dist/feeds/server/index.cjs.map +1 -1
  46. package/dist/feeds/server/index.js +26 -13
  47. package/dist/feeds/server/index.js.map +1 -1
  48. package/dist/google/apiClient.d.ts +8 -1
  49. package/dist/google/calendar.d.ts +35 -0
  50. package/dist/google/calendarSyncStore.d.ts +7 -0
  51. package/dist/google/collectionSync.d.ts +70 -0
  52. package/dist/google/index.cjs +294 -4
  53. package/dist/google/index.cjs.map +1 -1
  54. package/dist/google/index.d.ts +4 -2
  55. package/dist/google/index.js +279 -5
  56. package/dist/google/index.js.map +1 -1
  57. package/dist/ids-Dl9Ij63K.cjs +163 -0
  58. package/dist/ids-Dl9Ij63K.cjs.map +1 -0
  59. package/dist/ids-P2T89Gou.js +92 -0
  60. package/dist/ids-P2T89Gou.js.map +1 -0
  61. package/dist/{ingestTypes-CFEgTItj.js → ingestTypes-C-IQB4nw.js} +2 -2
  62. package/dist/{ingestTypes-CFEgTItj.js.map → ingestTypes-C-IQB4nw.js.map} +1 -1
  63. package/dist/{ingestTypes-Stc7iQyA.cjs → ingestTypes-DZpAC3N_.cjs} +3 -3
  64. package/dist/{ingestTypes-Stc7iQyA.cjs.map → ingestTypes-DZpAC3N_.cjs.map} +1 -1
  65. package/dist/project-BWI5w_BT.cjs +17 -0
  66. package/dist/project-BWI5w_BT.cjs.map +1 -0
  67. package/dist/project-bU98ycsy.js +12 -0
  68. package/dist/project-bU98ycsy.js.map +1 -0
  69. package/dist/{promptSafety-BLUcImzd.js → promptSafety-Boz5qOs-.js} +2 -84
  70. package/dist/promptSafety-Boz5qOs-.js.map +1 -0
  71. package/dist/{promptSafety-DI_1Rvhk.cjs → promptSafety-Co4cYQ_Z.cjs} +1 -149
  72. package/dist/promptSafety-Co4cYQ_Z.cjs.map +1 -0
  73. package/dist/remote-host/server/index.cjs +6 -4
  74. package/dist/remote-host/server/index.cjs.map +1 -1
  75. package/dist/remote-host/server/index.js +6 -4
  76. package/dist/remote-host/server/index.js.map +1 -1
  77. package/dist/remote-view/index.cjs +5 -3
  78. package/dist/remote-view/index.cjs.map +1 -1
  79. package/dist/remote-view/index.d.ts +3 -1
  80. package/dist/remote-view/index.js +5 -3
  81. package/dist/remote-view/index.js.map +1 -1
  82. package/dist/scheduler/index.cjs +3 -1
  83. package/dist/scheduler/index.cjs.map +1 -1
  84. package/dist/scheduler/index.js +3 -1
  85. package/dist/scheduler/index.js.map +1 -1
  86. package/dist/server-BI3wnTwl.js +1529 -0
  87. package/dist/server-BI3wnTwl.js.map +1 -0
  88. package/dist/server-CKdWENZ-.cjs +1704 -0
  89. package/dist/server-CKdWENZ-.cjs.map +1 -0
  90. package/dist/skill-bridge/index.cjs +2 -2
  91. package/dist/skill-bridge/index.cjs.map +1 -1
  92. package/dist/skill-bridge/index.js +1 -1
  93. package/dist/templatePath-27vUZowm.cjs +75 -0
  94. package/dist/templatePath-27vUZowm.cjs.map +1 -0
  95. package/dist/templatePath-k_WNbL_Q.js +52 -0
  96. package/dist/templatePath-k_WNbL_Q.js.map +1 -0
  97. package/package.json +1 -1
  98. package/dist/collection/paths.cjs.map +0 -1
  99. package/dist/collection/paths.js.map +0 -1
  100. package/dist/promptSafety-BLUcImzd.js.map +0 -1
  101. package/dist/promptSafety-DI_1Rvhk.cjs.map +0 -1
  102. package/dist/server-BpEo0FOd.js.map +0 -1
  103. package/dist/server-CkysfLh4.cjs.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","names":[],"sources":["../../../src/remote-host/server/hostRunner.ts","../../../src/remote-host/server/lifecycle.ts","../../../src/remote-host/server/auth.ts","../../../src/remote-host/server/sessionPersistence.ts","../../../src/remote-host/server/firebase.ts"],"sourcesContent":["// Host side of the command channel: claim queued commands, run handlers, write\n// results back, and announce presence via heartbeat.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/hostRunner.ts (itself\n// ported from ../mulmoserver). The only signature change vs. that copy: the\n// `firestore` instance is a parameter (each host supplies its own Firebase init),\n// and the heartbeat interval is an option (defaults to one minute).\nimport { DocumentReference, Firestore, deleteDoc, onSnapshot, query, runTransaction, serverTimestamp, setDoc, updateDoc, where } from \"firebase/firestore\";\n\nimport { errorMessage } from \"../../collection/core/errorMessage.js\";\nimport {\n Channel,\n Command,\n CommandHandler,\n CommandHandlers,\n JsonObject,\n buildHostPresence,\n byCreatedAt,\n commandsCollection,\n hostDoc,\n isExpired,\n} from \"../index.js\";\n\nconst DEFAULT_HEARTBEAT_MS = 60_000;\n\nexport interface HostEvent {\n phase: \"received\" | \"done\" | \"error\";\n method: string;\n message?: string;\n}\n\nexport interface HostRunnerOptions {\n onEvent?: (event: HostEvent) => void;\n // Called once when the listener dies fatally (after presence has been set\n // offline), so the lifecycle owner can reconcile its own state — e.g. clear\n // the runner handle so status() no longer reports connected. NOT called on a\n // normal stop().\n onClosed?: () => void;\n // Called when a command is dropped for being past its `expiresAt`, BEFORE the\n // doc is deleted, so the host can clean up out-of-band resources the command\n // referenced (e.g. staged attachment uploads in Storage). `uid` is THIS runner's\n // session uid (channel.uid) — passed in rather than read from a global so a\n // concurrent reconnect as a different account can't point cleanup at the wrong\n // user's Storage path. Best-effort: a throw is logged via onEvent and does NOT\n // block the doc deletion. Absent ⇒ the expired doc is simply deleted.\n onExpire?: (command: Command, uid: string) => void | Promise<void>;\n // Presence heartbeat interval; defaults to one minute.\n heartbeatMs?: number;\n}\n\ninterface Claim {\n method: string;\n params: JsonObject;\n}\n\nconst noop = () => undefined;\n\n// The remote may have deleted the doc on timeout, so ignore write-after-delete.\nconst writeError = (ref: DocumentReference, code: string, message: string) =>\n updateDoc(ref, { status: \"error\", error: { code, message }, updatedAt: serverTimestamp() }).catch(noop);\n\n// Atomically move a command queued -> processing so it is handled exactly once.\n// Returns the method/params to run, or null if another handler already took it.\nconst claimCommand = (firestore: Firestore, ref: DocumentReference): Promise<Claim | null> =>\n runTransaction(firestore, async (txn) => {\n const data = (await txn.get(ref)).data() as Command | undefined;\n if (!data || data.status !== \"queued\") {\n return null;\n }\n txn.update(ref, { status: \"processing\", updatedAt: serverTimestamp() });\n return { method: data.method, params: data.params ?? {} };\n });\n\nconst runHandler = async (ref: DocumentReference, claim: Claim, handler: CommandHandler): Promise<HostEvent> => {\n try {\n const result = await handler(claim.params);\n await updateDoc(ref, { status: \"done\", result: result ?? null, updatedAt: serverTimestamp() });\n return { phase: \"done\", method: claim.method };\n } catch (error) {\n const message = errorMessage(error);\n await writeError(ref, \"handler_error\", message);\n return { phase: \"error\", method: claim.method, message };\n }\n};\n\n// A command past its deadline is removed entirely rather than run: give the host\n// a chance to clean up out-of-band resources (staged attachments), then delete\n// the doc so it is neither reprocessed nor left as a stale error. Both steps are\n// best-effort/idempotent, so a snapshot replay surfacing the same expired doc\n// twice is harmless (no claim transaction needed — see plan edge #3).\nconst expireCommand = async (ref: DocumentReference, command: Command, options: HostRunnerOptions, uid: string) => {\n try {\n await options.onExpire?.(command, uid);\n } catch (error) {\n options.onEvent?.({ phase: \"error\", method: command.method, message: `onExpire failed: ${errorMessage(error)}` });\n }\n // Surface a delete failure (permissions / transient network) the same way the\n // onExpire failure above is surfaced — otherwise the expired doc lingers as\n // \"queued\" with no signal as to why cleanup didn't happen.\n await deleteDoc(ref).catch((error) => {\n options.onEvent?.({ phase: \"error\", method: command.method, message: `expire delete failed: ${errorMessage(error)}` });\n });\n options.onEvent?.({ phase: \"done\", method: command.method, message: \"expired\" });\n};\n\n// Per-runner constants bundled into one context so processCommand stays under the\n// max-params cap: firestore, the handler table, options, and the session uid are\n// all fixed for the runner's lifetime; only ref/command/now vary per command.\ninterface RunnerContext {\n firestore: Firestore;\n handlers: CommandHandlers;\n options: HostRunnerOptions;\n uid: string;\n}\n\nconst processCommand = async (ctx: RunnerContext, ref: DocumentReference, command: Command, now: number) => {\n const { handlers, options } = ctx;\n // Drop an expired command before claiming it — it must never reach a handler.\n if (isExpired(command, now)) {\n await expireCommand(ref, command, options, ctx.uid);\n return;\n }\n const claim = await claimCommand(ctx.firestore, ref);\n if (!claim) {\n return;\n }\n options.onEvent?.({ phase: \"received\", method: claim.method });\n const handler: CommandHandler | undefined = handlers[claim.method];\n if (!handler) {\n await writeError(ref, \"unknown_method\", `No handler for method: ${claim.method}`);\n options.onEvent?.({ phase: \"error\", method: claim.method, message: \"unknown method\" });\n return;\n }\n options.onEvent?.(await runHandler(ref, claim, handler));\n};\n\n// startHostRunner subscribes to queued commands for the given channel and runs\n// each one through the supplied handler table. It also announces presence (a\n// heartbeat on users/{uid}/hosts/{hostId}) so the remote can tell it is online.\n// Returns a stop function that goes offline and detaches the listener.\nexport const startHostRunner = (firestore: Firestore, channel: Channel, handlers: CommandHandlers, options: HostRunnerOptions = {}): (() => void) => {\n const presence = hostDoc(firestore, channel);\n // Advertise online/offline + the capability set (method names + protocol\n // version) on the same doc the remote already listens to for presence.\n const writePresence = (online: boolean) => setDoc(presence, { ...buildHostPresence(channel, handlers, online), updatedAt: serverTimestamp() }).catch(noop);\n const announce = () => {\n writePresence(true);\n };\n announce();\n const beat = setInterval(announce, options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS);\n\n const queuedCommands = query(commandsCollection(firestore, channel), where(\"status\", \"==\", \"queued\"));\n const ctx: RunnerContext = { firestore, handlers, options, uid: channel.uid };\n const unsubscribe = onSnapshot(\n queuedCommands,\n (snapshot) => {\n const now = Date.now();\n // Best-effort oldest-first DISPATCH only. Commands are processed\n // concurrently (not awaited in turn) and out-of-order completion is fine by\n // design — chat is asynchronous — so this sort just biases which command\n // starts first; it is not an ordering guarantee. We still sort in memory\n // rather than orderBy(\"createdAt\") on the query because a Firestore orderBy\n // silently EXCLUDES docs missing the field — which would drop every\n // pre-offline-queue command (no createdAt) from the queue entirely.\n const added = snapshot\n .docChanges()\n .filter((change) => change.type === \"added\")\n .map((change) => ({ ref: change.doc.ref, command: change.doc.data() as Command }))\n .sort((left, right) => byCreatedAt(left.command, right.command));\n added.forEach(({ ref, command }) => {\n processCommand(ctx, ref, command, now).catch(noop);\n });\n },\n (error) => {\n options.onEvent?.({ phase: \"error\", method: \"listen\", message: error.message });\n // A Firestore onSnapshot error terminates the listener and it does not\n // recover on its own. Stop advertising presence (clear the heartbeat +\n // write online:false) so remotes see the host as offline instead of a\n // live host that silently consumes no commands.\n clearInterval(beat);\n writePresence(false);\n options.onClosed?.();\n },\n );\n\n return () => {\n clearInterval(beat);\n writePresence(false);\n unsubscribe();\n };\n};\n","// Remote-host lifecycle: wire a Firebase session to the Firestore host runner so\n// connecting starts the command loop + presence heartbeat, and disconnecting\n// stops both.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/index.ts. The factory\n// takes injected collaborators — real hosts bind them to Firebase; tests pass\n// fakes to exercise the invariants (non-destructive connect, serialized\n// transitions, status/liveness reconciliation on fatal listener death) without a\n// network. `hostId` and the logger are injected too, so this file imports no\n// Firebase and no host logger and stays trivially unit-testable.\n//\n// Single-account, single-host per instance, in-memory session: a host restart\n// drops the session and needs a re-connect.\nimport type { Channel, Command, CommandHandlers } from \"../index.js\";\nimport type { HostRunnerOptions } from \"./hostRunner.js\";\n\nexport interface RemoteHostStatus {\n connected: boolean;\n uid: string | null;\n}\n\n// Minimal logger the factory calls; each host adapts its own logger to this\n// shape (or omits it to run silently, as the tests do).\nexport interface RemoteHostLogger {\n info: (msg: string) => void;\n warn: (msg: string) => void;\n debug: (msg: string) => void;\n}\n\n// Injectable collaborators — a real host binds these to Firebase + its own\n// hostId; tests pass fakes to exercise the lifecycle without a network.\n// `startRunner` is the host's `startHostRunner` pre-bound with its Firestore\n// instance, so this module needs no Firebase of its own.\nexport interface RemoteHostDeps {\n hostId: string;\n signIn: (idToken: string) => Promise<string>;\n // Restore a session from a browser-parked blob (case A', mulmoserver#50) and\n // resolve to its uid — no popup. MUST reject when the blob yields no valid\n // session so `reconnect` stays non-destructive. Absent ⇒ `reconnect` throws.\n restore?: (sessionBlob: string) => Promise<string>;\n signOut: () => Promise<void>;\n currentUid: () => string | null;\n startRunner: (channel: Channel, handlers: CommandHandlers, options: HostRunnerOptions) => () => void;\n handlers: CommandHandlers;\n // Optional host-specific cleanup for a command the runner drops as expired\n // (e.g. delete its staged attachment uploads). Threaded verbatim into the\n // runner's `onExpire`, which supplies the session `uid` so cleanup targets the\n // right user's Storage path even across a reconnect; absent ⇒ an expired doc is\n // just deleted.\n onExpire?: (command: Command, uid: string) => void | Promise<void>;\n log?: RemoteHostLogger;\n}\n\nexport interface RemoteHostLifecycle {\n connect: (idToken: string) => Promise<RemoteHostStatus>;\n // Restore a browser-parked session and start the runner — popup-free re-attach\n // after a host restart. Non-destructive like `connect`. Rejects if `deps.restore`\n // isn't wired, or if restore fails (blob expired/invalid → caller falls back to connect).\n reconnect: (sessionBlob: string) => Promise<RemoteHostStatus>;\n disconnect: () => Promise<RemoteHostStatus>;\n status: () => RemoteHostStatus;\n}\n\nconst noop = () => undefined;\nconst silentLogger: RemoteHostLogger = { info: noop, warn: noop, debug: noop };\n\nexport const createRemoteHost = (deps: RemoteHostDeps): RemoteHostLifecycle => {\n const log = deps.log ?? silentLogger;\n\n // The running host runner's stop() handle, or null when disconnected. Keeps\n // the single-host invariant — one runner at a time.\n let stopRunner: (() => void) | null = null;\n\n // Serialize connect/disconnect so overlapping requests can't both mutate\n // stopRunner (which would leak a second runner) or race auth against teardown.\n // Each transition runs only after the previous one settles; a failed\n // transition does not block the next (both handlers run the next op).\n let transition: Promise<unknown> = Promise.resolve();\n\n const serialize = <T>(operation: () => Promise<T>): Promise<T> => {\n const next = transition.then(operation, operation);\n transition = next.then(noop, noop);\n return next;\n };\n\n const stopIfRunning = () => {\n if (stopRunner) {\n stopRunner();\n stopRunner = null;\n }\n };\n\n const status = (): RemoteHostStatus => ({ connected: stopRunner !== null, uid: deps.currentUid() });\n\n // Swap the running runner for one bound to `uid`. Callers authenticate FIRST\n // (before this teardown) so a failed connect/reconnect keeps a healthy session.\n const attach = (uid: string, verb: string): RemoteHostStatus => {\n stopIfRunning();\n const runner = deps.startRunner({ uid, hostId: deps.hostId }, deps.handlers, {\n onEvent: (event) => log.debug(`host event: ${event.phase} ${event.method}`),\n onExpire: deps.onExpire,\n // Fatal listener death: drop the handle so status() stops reporting\n // connected — but only if it still points at THIS runner (a later\n // reconnect may have replaced it).\n onClosed: () => {\n if (stopRunner === runner) {\n stopRunner = null;\n log.warn(\"host runner listener died; marked disconnected\");\n }\n },\n });\n stopRunner = runner;\n log.info(`${verb} as ${uid}, host runner started (hostId=${deps.hostId})`);\n return status();\n };\n\n const connect = (idToken: string): Promise<RemoteHostStatus> => serialize(async () => attach(await deps.signIn(idToken), \"connected\"));\n\n const reconnect = (sessionBlob: string): Promise<RemoteHostStatus> =>\n serialize(async () => {\n const { restore } = deps;\n if (!restore) throw new Error(\"remote-host reconnect requires a `restore` dependency\");\n return attach(await restore(sessionBlob), \"reconnected\");\n });\n\n const disconnect = (): Promise<RemoteHostStatus> =>\n serialize(async () => {\n stopIfRunning();\n await deps.signOut();\n log.info(\"disconnected, host runner stopped\");\n return status();\n });\n\n return { connect, reconnect, disconnect, status };\n};\n","// Firebase credential exchange for the remote-host runner.\n//\n// The host authenticates to Firestore *as the user* (Option B) using the\n// Firebase JS SDK's signInWithCredential with a browser-minted Google OAuth ID\n// token — no Admin SDK, no project service account. Security rules keep the host\n// scoped to that user's own users/{uid}/… subtree.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/auth.ts. The `auth`\n// instance is a parameter so each host binds its own Firebase init; the factory\n// returns the low-level credential primitives (the connect/disconnect lifecycle\n// that starts/stops the host runner lives in createRemoteHost).\nimport { Auth, GoogleAuthProvider, signInWithCredential, signOut } from \"firebase/auth\";\n\nexport interface RemoteHostAuth {\n // Establish the Firebase session from a browser-minted Google OAuth ID token.\n // The token is used once here; the JS SDK then holds its own refresh token for\n // the process lifetime. Resolves to the authenticated uid.\n signInHost: (idToken: string) => Promise<string>;\n // Tear down the Firebase session (in-memory persistence → re-login on restart).\n signOutHost: () => Promise<void>;\n // The currently signed-in uid, or null when disconnected.\n currentUid: () => string | null;\n}\n\nexport const createRemoteHostAuth = (auth: Auth): RemoteHostAuth => ({\n signInHost: async (idToken: string): Promise<string> => {\n const credential = GoogleAuthProvider.credential(idToken);\n const userCredential = await signInWithCredential(auth, credential);\n return userCredential.user.uid;\n },\n signOutHost: (): Promise<void> => signOut(auth),\n currentUid: (): string | null => auth.currentUser?.uid ?? null,\n});\n","// A custom Firebase Auth persistence that keeps the signed-in session in memory\n// but can be seeded from — and exported back to — an opaque blob, so a host's\n// Firebase session survives a process restart by being parked in the browser's\n// localStorage (mulmoserver#50, \"case A'\"). The blob is whatever the SDK wrote\n// into persistence, round-tripped through JSON; we NEVER interpret its fields,\n// so we don't couple to the SDK's serialized-user format across versions.\n//\n// **Persistence is a CLASS, not an instance.** `initializeAuth(app, { persistence })`\n// hands the value to the SDK's `_getInstance(cls)`, which asserts `cls instanceof\n// Function` (\"Expected a class definition\") and then `new cls()`. Passing a plain\n// object throws — the SDK's own `inMemoryPersistence` is likewise a class. The\n// class is defined per factory call so its instances share this call's store;\n// `type: \"LOCAL\"` makes the SDK treat it as durable and persist the user.\n//\n// Seed BEFORE `initializeAuth` — the SDK reads persistence once at init. `onChange`\n// fires when the SDK writes/removes a key (a token update that rotates the stored\n// blob, or a sign-out) — the signal to re-sync the browser copy.\nimport type { Persistence } from \"firebase/auth\";\n\n// The SDK stores values as JSON objects (a serialized user) or strings. We treat\n// them opaquely — a value is either.\ntype PersistenceValue = Record<string, unknown> | string;\n\ntype StorageListener = (value: PersistenceValue | null) => void;\n\n// The instance the SDK builds via `new` and then drives with these `_`-methods.\nexport interface HostAuthPersistenceInstance extends Persistence {\n type: \"LOCAL\";\n _isAvailable: () => Promise<boolean>;\n _set: (key: string, value: PersistenceValue) => Promise<void>;\n _get: (key: string) => Promise<PersistenceValue | null>;\n _remove: (key: string) => Promise<void>;\n _addListener: (key: string, listener: StorageListener) => void;\n _removeListener: (key: string, listener: StorageListener) => void;\n}\n\n// The class value passed to `initializeAuth`. The static `type` makes it\n// assignable to the SDK's `Persistence` (a class-valued-typed-as-instance, the\n// same trick the SDK uses for `inMemoryPersistence`); `new ()` lets the SDK — and\n// the tests — instantiate it.\nexport interface HostAuthPersistenceClass extends Persistence {\n type: \"LOCAL\";\n new (): HostAuthPersistenceInstance;\n}\n\nexport interface HostSessionPersistence {\n /** Pass to `initializeAuth(app, { persistence })`. It's a class the SDK `new`s. */\n persistence: HostAuthPersistenceClass;\n /** Load a previously exported blob. Call BEFORE `initializeAuth`. */\n seed: (blob: string) => void;\n /** Serialize the current contents, or `null` when empty (no session). */\n exportBlob: () => string | null;\n /** Notified with the fresh blob (or `null`) whenever the SDK writes/removes. */\n onChange: (listener: (blob: string | null) => void) => () => void;\n /** Drop all contents (used when tearing down the Firebase app). */\n clear: () => void;\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> => typeof value === \"object\" && value !== null && !Array.isArray(value);\n\nconst isPersistenceValue = (value: unknown): value is PersistenceValue => typeof value === \"string\" || isRecord(value);\n\n// True when `blob` is a JSON object `seed` can load. A blob that fails this can\n// never restore a session (corrupt localStorage, wrong shape), so callers treat\n// it as an expired/invalid session rather than a transient error — the client\n// then drops it instead of retrying the same doomed blob forever.\nexport const isSeedableBlob = (blob: string): boolean => {\n try {\n return isRecord(JSON.parse(blob));\n } catch {\n return false;\n }\n};\n\n// A class (constructor) so the SDK's `_getInstance` accepts it (it asserts\n// `cls instanceof Function`, then `new cls()`); instances hold the shared\n// `store`/`notify`, so a fresh `new` still sees the seeded/live data. `static\n// type` is for the `Persistence` type bridge; the instance `type` is what the\n// SDK reads after `new`.\nconst makeHostPersistenceClass = (store: Map<string, PersistenceValue>, notify: () => void): HostAuthPersistenceClass => {\n class HostAuthPersistence {\n static readonly type = \"LOCAL\" as const;\n readonly type = \"LOCAL\" as const;\n private readonly store = store;\n private readonly notify = notify;\n private readonly external = new Set<StorageListener>();\n _isAvailable(): Promise<boolean> {\n return Promise.resolve(this.store instanceof Map);\n }\n async _set(key: string, value: PersistenceValue): Promise<void> {\n this.store.set(key, value);\n this.notify();\n }\n _get(key: string): Promise<PersistenceValue | null> {\n return Promise.resolve(this.store.get(key) ?? null);\n }\n async _remove(key: string): Promise<void> {\n this.store.delete(key);\n this.notify();\n }\n // Node has no cross-tab storage events to deliver, so registered listeners\n // never fire; we still track them so add/remove stay symmetric.\n _addListener(_key: string, listener: StorageListener): void {\n this.external.add(listener);\n }\n _removeListener(_key: string, listener: StorageListener): void {\n this.external.delete(listener);\n }\n }\n return HostAuthPersistence;\n};\n\nexport const createHostSessionPersistence = (): HostSessionPersistence => {\n const store = new Map<string, PersistenceValue>();\n const listeners = new Set<(blob: string | null) => void>();\n\n const exportBlob = (): string | null => (store.size === 0 ? null : JSON.stringify(Object.fromEntries(store)));\n\n const notify = (): void => {\n const blob = exportBlob();\n listeners.forEach((listener) => listener(blob));\n };\n\n const persistence = makeHostPersistenceClass(store, notify);\n\n const seed = (blob: string): void => {\n const parsed: unknown = JSON.parse(blob);\n if (!isRecord(parsed)) throw new Error(\"host session blob must be a JSON object\");\n store.clear();\n for (const [key, value] of Object.entries(parsed)) {\n if (isPersistenceValue(value)) store.set(key, value);\n }\n };\n\n const onChange = (listener: (blob: string | null) => void): (() => void) => {\n listeners.add(listener);\n return () => listeners.delete(listener);\n };\n\n const clear = (): void => {\n store.clear();\n };\n\n return { persistence, seed, exportBlob, onChange, clear };\n};\n","// Firebase init for a remote-host runner.\n//\n// A host acts as a *host*: it signs in to Firebase as the user (via\n// signInWithCredential, see auth.ts) and listens to that user's command queue in\n// Firestore. The modular firebase/firestore + firebase/auth SDKs run in Node, so\n// this mirrors a browser init but also exposes Firestore (default database,\n// which must be in Native mode) and Storage.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/firebase.ts. The\n// public web config is a parameter so each host supplies its own (both hosts\n// reuse the shared mulmoserver project).\nimport { deleteApp, FirebaseApp, FirebaseOptions, initializeApp } from \"firebase/app\";\nimport { Auth, getAuth, initializeAuth } from \"firebase/auth\";\nimport { Firestore, getFirestore } from \"firebase/firestore\";\nimport { FirebaseStorage, getStorage } from \"firebase/storage\";\n\nimport { createHostSessionPersistence, type HostSessionPersistence } from \"./sessionPersistence.js\";\n\nexport interface RemoteHostFirebase {\n app: FirebaseApp;\n auth: Auth;\n firestore: Firestore;\n // Storage carries the full-res attachment bytes the command channel can't (a\n // Firestore command doc caps at ~1 MiB). The host, signed in as the user,\n // pulls each staged upload from `users/{uid}/uploads/{id}` and deletes it\n // after ingest.\n storage: FirebaseStorage;\n}\n\nexport const createRemoteHostFirebase = (config: FirebaseOptions): RemoteHostFirebase => {\n const app = initializeApp(config);\n return { app, auth: getAuth(app), firestore: getFirestore(app), storage: getStorage(app) };\n};\n\nexport interface RemoteHostSessionHandles {\n auth: Auth;\n firestore: Firestore;\n storage: FirebaseStorage;\n // The signed-in uid restored from a seed blob, or null when opened fresh\n // (before `signInHost`) or when the blob held no valid session.\n uid: string | null;\n}\n\n// A restartable Firebase session for a host, backed by the export/seed-able\n// persistence (mulmoserver#50, \"case A'\"). Because `initializeAuth` reads\n// persistence once and can run only once per app, each `open` spins up a FRESH\n// app (unique name) with the persistence seeded first, then tears down the\n// previous app — so a reconnect can restore a browser-parked session, and a\n// fresh connect starts clean. `exportSession`/`onSessionChange` expose the blob\n// the browser stores and the signal to re-sync it.\n// Runs on the freshly opened handles BEFORE the previous session is torn down.\n// Reject to keep the open non-destructive: the fresh app is rolled back and the\n// previous session is preserved (so a failed sign-in or an expired blob doesn't\n// replace a healthy live session).\nexport type RemoteHostSessionValidate = (handles: RemoteHostSessionHandles) => Promise<void>;\n\nexport interface RemoteHostSession {\n open: (seedBlob?: string, validate?: RemoteHostSessionValidate) => Promise<RemoteHostSessionHandles>;\n close: () => Promise<void>;\n exportSession: () => string | null;\n onSessionChange: (cb: (blob: string | null) => void) => () => void;\n}\n\nconst noop = (): void => undefined;\n\n// Run operations one-at-a-time in submission order. `open`/`close` mutate shared\n// store/app state, so overlapping calls must not interleave (they would leak an\n// app or leave `app` pointing at a torn-down instance). Mirrors the lifecycle's\n// serialization; a failed op doesn't block the next.\nconst makeSerializer = () => {\n let transition: Promise<unknown> = Promise.resolve();\n return <T>(operation: () => Promise<T>): Promise<T> => {\n const next = transition.then(operation, operation);\n transition = next.then(noop, noop);\n return next;\n };\n};\n\n// Build a fresh app from the (already seeded) persistence and wait for the\n// restored auth state to settle. Persistence restore is async, so `uid`\n// reflects a seeded session only after `authStateReady()` (null when the blob\n// was empty/invalid, or on a fresh connect before `signInHost`).\nconst openFreshApp = async (\n config: FirebaseOptions,\n store: HostSessionPersistence,\n name: string,\n): Promise<{ app: FirebaseApp; handles: RemoteHostSessionHandles }> => {\n // `initializeApp` registers the app in the SDK's global registry, so if init\n // then throws we must delete it — otherwise repeated reconnect failures leak\n // a registered app each time.\n const app = initializeApp(config, name);\n try {\n const auth = initializeAuth(app, { persistence: store.persistence });\n await auth.authStateReady();\n return { app, handles: { auth, firestore: getFirestore(app), storage: getStorage(app), uid: auth.currentUser?.uid ?? null } };\n } catch (error) {\n await deleteApp(app).catch(() => undefined);\n throw error;\n }\n};\n\nexport const createRemoteHostSession = (config: FirebaseOptions): RemoteHostSession => {\n const store = createHostSessionPersistence();\n let app: FirebaseApp | null = null;\n let appSeq = 0;\n const serialize = makeSerializer();\n\n const closeInner = async (): Promise<void> => {\n const previous = app;\n app = null;\n store.clear();\n if (previous) await deleteApp(previous);\n };\n\n // Run the caller's check on the fresh handles; on rejection, delete the fresh\n // app so only the caller-visible error escapes and the previous session (never\n // touched here) stays live.\n const validateOrRollback = async (nextApp: FirebaseApp, handles: RemoteHostSessionHandles, validate?: RemoteHostSessionValidate): Promise<void> => {\n if (!validate) return;\n try {\n await validate(handles);\n } catch (error) {\n await deleteApp(nextApp).catch(() => undefined);\n throw error;\n }\n };\n\n const openInner = async (seedBlob?: string, validate?: RemoteHostSessionValidate): Promise<RemoteHostSessionHandles> => {\n // Non-destructive: keep the current session intact until the fresh app is\n // proven to come up AND pass `validate` (sign-in / uid). A bad seed blob, a\n // failed init, or a rejected validation must not tear down a healthy session\n // — the reconnect contract (mulmoserver#50). So we tear the previous app\n // down only AFTER success, and roll the store back on failure.\n const previousApp = app;\n const previousBlob = store.exportBlob();\n appSeq += 1;\n try {\n store.clear();\n if (seedBlob) store.seed(seedBlob);\n const { app: nextApp, handles } = await openFreshApp(config, store, `remote-host-${appSeq}`);\n await validateOrRollback(nextApp, handles, validate);\n app = nextApp;\n if (previousApp) await deleteApp(previousApp).catch(() => undefined);\n return handles;\n } catch (error) {\n store.clear();\n if (previousBlob) store.seed(previousBlob);\n throw error;\n }\n };\n\n return {\n open: (seedBlob?: string, validate?: RemoteHostSessionValidate) => serialize(() => openInner(seedBlob, validate)),\n close: () => serialize(closeInner),\n exportSession: store.exportBlob,\n onSessionChange: store.onChange,\n };\n};\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.cjs","names":[],"sources":["../../../src/remote-host/server/hostRunner.ts","../../../src/remote-host/server/lifecycle.ts","../../../src/remote-host/server/auth.ts","../../../src/remote-host/server/sessionPersistence.ts","../../../src/remote-host/server/firebase.ts"],"sourcesContent":["// Host side of the command channel: claim queued commands, run handlers, write\n// results back, and announce presence via heartbeat.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/hostRunner.ts (itself\n// ported from ../mulmoserver). The only signature change vs. that copy: the\n// `firestore` instance is a parameter (each host supplies its own Firebase init),\n// and the heartbeat interval is an option (defaults to one minute).\nimport { DocumentReference, Firestore, deleteDoc, onSnapshot, query, runTransaction, serverTimestamp, setDoc, updateDoc, where } from \"firebase/firestore\";\n\nimport { errorMessage } from \"../../collection/core/errorMessage.js\";\nimport {\n Channel,\n Command,\n CommandHandler,\n CommandHandlers,\n JsonObject,\n buildHostPresence,\n byCreatedAt,\n commandsCollection,\n hostDoc,\n isExpired,\n} from \"../index.js\";\n\nconst DEFAULT_HEARTBEAT_MS = 60_000;\n\nexport interface HostEvent {\n phase: \"received\" | \"done\" | \"error\";\n method: string;\n message?: string;\n}\n\nexport interface HostRunnerOptions {\n onEvent?: (event: HostEvent) => void;\n // Called once when the listener dies fatally (after presence has been set\n // offline), so the lifecycle owner can reconcile its own state — e.g. clear\n // the runner handle so status() no longer reports connected. NOT called on a\n // normal stop().\n onClosed?: () => void;\n // Called when a command is dropped for being past its `expiresAt`, BEFORE the\n // doc is deleted, so the host can clean up out-of-band resources the command\n // referenced (e.g. staged attachment uploads in Storage). `uid` is THIS runner's\n // session uid (channel.uid) — passed in rather than read from a global so a\n // concurrent reconnect as a different account can't point cleanup at the wrong\n // user's Storage path. Best-effort: a throw is logged via onEvent and does NOT\n // block the doc deletion. Absent ⇒ the expired doc is simply deleted.\n onExpire?: (command: Command, uid: string) => void | Promise<void>;\n // Presence heartbeat interval; defaults to one minute.\n heartbeatMs?: number;\n}\n\ninterface Claim {\n method: string;\n params: JsonObject;\n}\n\nconst noop = () => undefined;\n\n// The remote may have deleted the doc on timeout, so ignore write-after-delete.\nconst writeError = (ref: DocumentReference, code: string, message: string) =>\n updateDoc(ref, { status: \"error\", error: { code, message }, updatedAt: serverTimestamp() }).catch(noop);\n\n// Atomically move a command queued -> processing so it is handled exactly once.\n// Returns the method/params to run, or null if another handler already took it.\nconst claimCommand = (firestore: Firestore, ref: DocumentReference): Promise<Claim | null> =>\n runTransaction(firestore, async (txn) => {\n const data = (await txn.get(ref)).data() as Command | undefined;\n if (!data || data.status !== \"queued\") {\n return null;\n }\n txn.update(ref, { status: \"processing\", updatedAt: serverTimestamp() });\n return { method: data.method, params: data.params ?? {} };\n });\n\nconst runHandler = async (ref: DocumentReference, claim: Claim, handler: CommandHandler): Promise<HostEvent> => {\n try {\n const result = await handler(claim.params);\n await updateDoc(ref, { status: \"done\", result: result ?? null, updatedAt: serverTimestamp() });\n return { phase: \"done\", method: claim.method };\n } catch (error) {\n const message = errorMessage(error);\n await writeError(ref, \"handler_error\", message);\n return { phase: \"error\", method: claim.method, message };\n }\n};\n\n// A command past its deadline is removed entirely rather than run: give the host\n// a chance to clean up out-of-band resources (staged attachments), then delete\n// the doc so it is neither reprocessed nor left as a stale error. Both steps are\n// best-effort/idempotent, so a snapshot replay surfacing the same expired doc\n// twice is harmless (no claim transaction needed — see plan edge #3).\nconst expireCommand = async (ref: DocumentReference, command: Command, options: HostRunnerOptions, uid: string) => {\n try {\n await options.onExpire?.(command, uid);\n } catch (error) {\n options.onEvent?.({ phase: \"error\", method: command.method, message: `onExpire failed: ${errorMessage(error)}` });\n }\n // Surface a delete failure (permissions / transient network) the same way the\n // onExpire failure above is surfaced — otherwise the expired doc lingers as\n // \"queued\" with no signal as to why cleanup didn't happen.\n await deleteDoc(ref).catch((error) => {\n options.onEvent?.({ phase: \"error\", method: command.method, message: `expire delete failed: ${errorMessage(error)}` });\n });\n options.onEvent?.({ phase: \"done\", method: command.method, message: \"expired\" });\n};\n\n// Per-runner constants bundled into one context so processCommand stays under the\n// max-params cap: firestore, the handler table, options, and the session uid are\n// all fixed for the runner's lifetime; only ref/command/now vary per command.\ninterface RunnerContext {\n firestore: Firestore;\n handlers: CommandHandlers;\n options: HostRunnerOptions;\n uid: string;\n}\n\nconst processCommand = async (ctx: RunnerContext, ref: DocumentReference, command: Command, now: number) => {\n const { handlers, options } = ctx;\n // Drop an expired command before claiming it — it must never reach a handler.\n if (isExpired(command, now)) {\n await expireCommand(ref, command, options, ctx.uid);\n return;\n }\n const claim = await claimCommand(ctx.firestore, ref);\n if (!claim) {\n return;\n }\n options.onEvent?.({ phase: \"received\", method: claim.method });\n const handler: CommandHandler | undefined = handlers[claim.method];\n if (!handler) {\n await writeError(ref, \"unknown_method\", `No handler for method: ${claim.method}`);\n options.onEvent?.({ phase: \"error\", method: claim.method, message: \"unknown method\" });\n return;\n }\n options.onEvent?.(await runHandler(ref, claim, handler));\n};\n\n// startHostRunner subscribes to queued commands for the given channel and runs\n// each one through the supplied handler table. It also announces presence (a\n// heartbeat on users/{uid}/hosts/{hostId}) so the remote can tell it is online.\n// Returns a stop function that goes offline and detaches the listener.\nexport const startHostRunner = (firestore: Firestore, channel: Channel, handlers: CommandHandlers, options: HostRunnerOptions = {}): (() => void) => {\n const presence = hostDoc(firestore, channel);\n // Advertise online/offline + the capability set (method names + protocol\n // version) on the same doc the remote already listens to for presence.\n // Returns void, not the promise: presence is advertised best-effort from\n // three call sites (announce, the snapshot-error path, the teardown), none of\n // which can await. Terminating the chain here with `.catch(noop)` keeps every\n // caller from floating a promise it has no way to handle.\n const writePresence = (online: boolean): void => {\n setDoc(presence, { ...buildHostPresence(channel, handlers, online), updatedAt: serverTimestamp() }).catch(noop);\n };\n const announce = () => {\n writePresence(true);\n };\n announce();\n const beat = setInterval(announce, options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS);\n\n const queuedCommands = query(commandsCollection(firestore, channel), where(\"status\", \"==\", \"queued\"));\n const ctx: RunnerContext = { firestore, handlers, options, uid: channel.uid };\n const unsubscribe = onSnapshot(\n queuedCommands,\n (snapshot) => {\n const now = Date.now();\n // Best-effort oldest-first DISPATCH only. Commands are processed\n // concurrently (not awaited in turn) and out-of-order completion is fine by\n // design — chat is asynchronous — so this sort just biases which command\n // starts first; it is not an ordering guarantee. We still sort in memory\n // rather than orderBy(\"createdAt\") on the query because a Firestore orderBy\n // silently EXCLUDES docs missing the field — which would drop every\n // pre-offline-queue command (no createdAt) from the queue entirely.\n const added = snapshot\n .docChanges()\n .filter((change) => change.type === \"added\")\n .map((change) => ({ ref: change.doc.ref, command: change.doc.data() as Command }))\n .sort((left, right) => byCreatedAt(left.command, right.command));\n added.forEach(({ ref, command }) => {\n processCommand(ctx, ref, command, now).catch(noop);\n });\n },\n (error) => {\n options.onEvent?.({ phase: \"error\", method: \"listen\", message: error.message });\n // A Firestore onSnapshot error terminates the listener and it does not\n // recover on its own. Stop advertising presence (clear the heartbeat +\n // write online:false) so remotes see the host as offline instead of a\n // live host that silently consumes no commands.\n clearInterval(beat);\n writePresence(false);\n options.onClosed?.();\n },\n );\n\n return () => {\n clearInterval(beat);\n writePresence(false);\n unsubscribe();\n };\n};\n","// Remote-host lifecycle: wire a Firebase session to the Firestore host runner so\n// connecting starts the command loop + presence heartbeat, and disconnecting\n// stops both.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/index.ts. The factory\n// takes injected collaborators — real hosts bind them to Firebase; tests pass\n// fakes to exercise the invariants (non-destructive connect, serialized\n// transitions, status/liveness reconciliation on fatal listener death) without a\n// network. `hostId` and the logger are injected too, so this file imports no\n// Firebase and no host logger and stays trivially unit-testable.\n//\n// Single-account, single-host per instance, in-memory session: a host restart\n// drops the session and needs a re-connect.\nimport type { Channel, Command, CommandHandlers } from \"../index.js\";\nimport type { HostRunnerOptions } from \"./hostRunner.js\";\n\nexport interface RemoteHostStatus {\n connected: boolean;\n uid: string | null;\n}\n\n// Minimal logger the factory calls; each host adapts its own logger to this\n// shape (or omits it to run silently, as the tests do).\nexport interface RemoteHostLogger {\n info: (msg: string) => void;\n warn: (msg: string) => void;\n debug: (msg: string) => void;\n}\n\n// Injectable collaborators — a real host binds these to Firebase + its own\n// hostId; tests pass fakes to exercise the lifecycle without a network.\n// `startRunner` is the host's `startHostRunner` pre-bound with its Firestore\n// instance, so this module needs no Firebase of its own.\nexport interface RemoteHostDeps {\n hostId: string;\n signIn: (idToken: string) => Promise<string>;\n // Restore a session from a browser-parked blob (case A', mulmoserver#50) and\n // resolve to its uid — no popup. MUST reject when the blob yields no valid\n // session so `reconnect` stays non-destructive. Absent ⇒ `reconnect` throws.\n restore?: (sessionBlob: string) => Promise<string>;\n signOut: () => Promise<void>;\n currentUid: () => string | null;\n startRunner: (channel: Channel, handlers: CommandHandlers, options: HostRunnerOptions) => () => void;\n handlers: CommandHandlers;\n // Optional host-specific cleanup for a command the runner drops as expired\n // (e.g. delete its staged attachment uploads). Threaded verbatim into the\n // runner's `onExpire`, which supplies the session `uid` so cleanup targets the\n // right user's Storage path even across a reconnect; absent ⇒ an expired doc is\n // just deleted.\n onExpire?: (command: Command, uid: string) => void | Promise<void>;\n log?: RemoteHostLogger;\n}\n\nexport interface RemoteHostLifecycle {\n connect: (idToken: string) => Promise<RemoteHostStatus>;\n // Restore a browser-parked session and start the runner — popup-free re-attach\n // after a host restart. Non-destructive like `connect`. Rejects if `deps.restore`\n // isn't wired, or if restore fails (blob expired/invalid → caller falls back to connect).\n reconnect: (sessionBlob: string) => Promise<RemoteHostStatus>;\n disconnect: () => Promise<RemoteHostStatus>;\n status: () => RemoteHostStatus;\n}\n\nconst noop = () => undefined;\nconst silentLogger: RemoteHostLogger = { info: noop, warn: noop, debug: noop };\n\nexport const createRemoteHost = (deps: RemoteHostDeps): RemoteHostLifecycle => {\n const log = deps.log ?? silentLogger;\n\n // The running host runner's stop() handle, or null when disconnected. Keeps\n // the single-host invariant — one runner at a time.\n let stopRunner: (() => void) | null = null;\n\n // Serialize connect/disconnect so overlapping requests can't both mutate\n // stopRunner (which would leak a second runner) or race auth against teardown.\n // Each transition runs only after the previous one settles; a failed\n // transition does not block the next (both handlers run the next op).\n let transition: Promise<unknown> = Promise.resolve();\n\n const serialize = <T>(operation: () => Promise<T>): Promise<T> => {\n const next = transition.then(operation, operation);\n transition = next.then(noop, noop);\n return next;\n };\n\n const stopIfRunning = () => {\n if (stopRunner) {\n stopRunner();\n stopRunner = null;\n }\n };\n\n const status = (): RemoteHostStatus => ({ connected: stopRunner !== null, uid: deps.currentUid() });\n\n // Swap the running runner for one bound to `uid`. Callers authenticate FIRST\n // (before this teardown) so a failed connect/reconnect keeps a healthy session.\n const attach = (uid: string, verb: string): RemoteHostStatus => {\n stopIfRunning();\n const runner = deps.startRunner({ uid, hostId: deps.hostId }, deps.handlers, {\n onEvent: (event) => log.debug(`host event: ${event.phase} ${event.method}`),\n onExpire: deps.onExpire,\n // Fatal listener death: drop the handle so status() stops reporting\n // connected — but only if it still points at THIS runner (a later\n // reconnect may have replaced it).\n onClosed: () => {\n if (stopRunner === runner) {\n stopRunner = null;\n log.warn(\"host runner listener died; marked disconnected\");\n }\n },\n });\n stopRunner = runner;\n log.info(`${verb} as ${uid}, host runner started (hostId=${deps.hostId})`);\n return status();\n };\n\n const connect = (idToken: string): Promise<RemoteHostStatus> => serialize(async () => attach(await deps.signIn(idToken), \"connected\"));\n\n const reconnect = (sessionBlob: string): Promise<RemoteHostStatus> =>\n serialize(async () => {\n const { restore } = deps;\n if (!restore) throw new Error(\"remote-host reconnect requires a `restore` dependency\");\n return attach(await restore(sessionBlob), \"reconnected\");\n });\n\n const disconnect = (): Promise<RemoteHostStatus> =>\n serialize(async () => {\n stopIfRunning();\n await deps.signOut();\n log.info(\"disconnected, host runner stopped\");\n return status();\n });\n\n return { connect, reconnect, disconnect, status };\n};\n","// Firebase credential exchange for the remote-host runner.\n//\n// The host authenticates to Firestore *as the user* (Option B) using the\n// Firebase JS SDK's signInWithCredential with a browser-minted Google OAuth ID\n// token — no Admin SDK, no project service account. Security rules keep the host\n// scoped to that user's own users/{uid}/… subtree.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/auth.ts. The `auth`\n// instance is a parameter so each host binds its own Firebase init; the factory\n// returns the low-level credential primitives (the connect/disconnect lifecycle\n// that starts/stops the host runner lives in createRemoteHost).\nimport { Auth, GoogleAuthProvider, signInWithCredential, signOut } from \"firebase/auth\";\n\nexport interface RemoteHostAuth {\n // Establish the Firebase session from a browser-minted Google OAuth ID token.\n // The token is used once here; the JS SDK then holds its own refresh token for\n // the process lifetime. Resolves to the authenticated uid.\n signInHost: (idToken: string) => Promise<string>;\n // Tear down the Firebase session (in-memory persistence → re-login on restart).\n signOutHost: () => Promise<void>;\n // The currently signed-in uid, or null when disconnected.\n currentUid: () => string | null;\n}\n\nexport const createRemoteHostAuth = (auth: Auth): RemoteHostAuth => ({\n signInHost: async (idToken: string): Promise<string> => {\n const credential = GoogleAuthProvider.credential(idToken);\n const userCredential = await signInWithCredential(auth, credential);\n return userCredential.user.uid;\n },\n signOutHost: (): Promise<void> => signOut(auth),\n currentUid: (): string | null => auth.currentUser?.uid ?? null,\n});\n","// A custom Firebase Auth persistence that keeps the signed-in session in memory\n// but can be seeded from — and exported back to — an opaque blob, so a host's\n// Firebase session survives a process restart by being parked in the browser's\n// localStorage (mulmoserver#50, \"case A'\"). The blob is whatever the SDK wrote\n// into persistence, round-tripped through JSON; we NEVER interpret its fields,\n// so we don't couple to the SDK's serialized-user format across versions.\n//\n// **Persistence is a CLASS, not an instance.** `initializeAuth(app, { persistence })`\n// hands the value to the SDK's `_getInstance(cls)`, which asserts `cls instanceof\n// Function` (\"Expected a class definition\") and then `new cls()`. Passing a plain\n// object throws — the SDK's own `inMemoryPersistence` is likewise a class. The\n// class is defined per factory call so its instances share this call's store;\n// `type: \"LOCAL\"` makes the SDK treat it as durable and persist the user.\n//\n// Seed BEFORE `initializeAuth` — the SDK reads persistence once at init. `onChange`\n// fires when the SDK writes/removes a key (a token update that rotates the stored\n// blob, or a sign-out) — the signal to re-sync the browser copy.\nimport type { Persistence } from \"firebase/auth\";\n\n// The SDK stores values as JSON objects (a serialized user) or strings. We treat\n// them opaquely — a value is either.\ntype PersistenceValue = Record<string, unknown> | string;\n\ntype StorageListener = (value: PersistenceValue | null) => void;\n\n// The instance the SDK builds via `new` and then drives with these `_`-methods.\nexport interface HostAuthPersistenceInstance extends Persistence {\n type: \"LOCAL\";\n _isAvailable: () => Promise<boolean>;\n _set: (key: string, value: PersistenceValue) => Promise<void>;\n _get: (key: string) => Promise<PersistenceValue | null>;\n _remove: (key: string) => Promise<void>;\n _addListener: (key: string, listener: StorageListener) => void;\n _removeListener: (key: string, listener: StorageListener) => void;\n}\n\n// The class value passed to `initializeAuth`. The static `type` makes it\n// assignable to the SDK's `Persistence` (a class-valued-typed-as-instance, the\n// same trick the SDK uses for `inMemoryPersistence`); `new ()` lets the SDK — and\n// the tests — instantiate it.\nexport interface HostAuthPersistenceClass extends Persistence {\n type: \"LOCAL\";\n new (): HostAuthPersistenceInstance;\n}\n\nexport interface HostSessionPersistence {\n /** Pass to `initializeAuth(app, { persistence })`. It's a class the SDK `new`s. */\n persistence: HostAuthPersistenceClass;\n /** Load a previously exported blob. Call BEFORE `initializeAuth`. */\n seed: (blob: string) => void;\n /** Serialize the current contents, or `null` when empty (no session). */\n exportBlob: () => string | null;\n /** Notified with the fresh blob (or `null`) whenever the SDK writes/removes. */\n onChange: (listener: (blob: string | null) => void) => () => void;\n /** Drop all contents (used when tearing down the Firebase app). */\n clear: () => void;\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> => typeof value === \"object\" && value !== null && !Array.isArray(value);\n\nconst isPersistenceValue = (value: unknown): value is PersistenceValue => typeof value === \"string\" || isRecord(value);\n\n// True when `blob` is a JSON object `seed` can load. A blob that fails this can\n// never restore a session (corrupt localStorage, wrong shape), so callers treat\n// it as an expired/invalid session rather than a transient error — the client\n// then drops it instead of retrying the same doomed blob forever.\nexport const isSeedableBlob = (blob: string): boolean => {\n try {\n return isRecord(JSON.parse(blob));\n } catch {\n return false;\n }\n};\n\n// A class (constructor) so the SDK's `_getInstance` accepts it (it asserts\n// `cls instanceof Function`, then `new cls()`); instances hold the shared\n// `store`/`notify`, so a fresh `new` still sees the seeded/live data. `static\n// type` is for the `Persistence` type bridge; the instance `type` is what the\n// SDK reads after `new`.\nconst makeHostPersistenceClass = (store: Map<string, PersistenceValue>, notify: () => void): HostAuthPersistenceClass => {\n class HostAuthPersistence {\n static readonly type = \"LOCAL\" as const;\n readonly type = \"LOCAL\" as const;\n private readonly store = store;\n private readonly notify = notify;\n private readonly external = new Set<StorageListener>();\n _isAvailable(): Promise<boolean> {\n return Promise.resolve(this.store instanceof Map);\n }\n async _set(key: string, value: PersistenceValue): Promise<void> {\n this.store.set(key, value);\n this.notify();\n }\n _get(key: string): Promise<PersistenceValue | null> {\n return Promise.resolve(this.store.get(key) ?? null);\n }\n async _remove(key: string): Promise<void> {\n this.store.delete(key);\n this.notify();\n }\n // Node has no cross-tab storage events to deliver, so registered listeners\n // never fire; we still track them so add/remove stay symmetric.\n _addListener(_key: string, listener: StorageListener): void {\n this.external.add(listener);\n }\n _removeListener(_key: string, listener: StorageListener): void {\n this.external.delete(listener);\n }\n }\n return HostAuthPersistence;\n};\n\nexport const createHostSessionPersistence = (): HostSessionPersistence => {\n const store = new Map<string, PersistenceValue>();\n const listeners = new Set<(blob: string | null) => void>();\n\n const exportBlob = (): string | null => (store.size === 0 ? null : JSON.stringify(Object.fromEntries(store)));\n\n const notify = (): void => {\n const blob = exportBlob();\n listeners.forEach((listener) => listener(blob));\n };\n\n const persistence = makeHostPersistenceClass(store, notify);\n\n const seed = (blob: string): void => {\n const parsed: unknown = JSON.parse(blob);\n if (!isRecord(parsed)) throw new Error(\"host session blob must be a JSON object\");\n store.clear();\n for (const [key, value] of Object.entries(parsed)) {\n if (isPersistenceValue(value)) store.set(key, value);\n }\n };\n\n const onChange = (listener: (blob: string | null) => void): (() => void) => {\n listeners.add(listener);\n return () => listeners.delete(listener);\n };\n\n const clear = (): void => {\n store.clear();\n };\n\n return { persistence, seed, exportBlob, onChange, clear };\n};\n","// Firebase init for a remote-host runner.\n//\n// A host acts as a *host*: it signs in to Firebase as the user (via\n// signInWithCredential, see auth.ts) and listens to that user's command queue in\n// Firestore. The modular firebase/firestore + firebase/auth SDKs run in Node, so\n// this mirrors a browser init but also exposes Firestore (default database,\n// which must be in Native mode) and Storage.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/firebase.ts. The\n// public web config is a parameter so each host supplies its own (both hosts\n// reuse the shared mulmoserver project).\nimport { deleteApp, FirebaseApp, FirebaseOptions, initializeApp } from \"firebase/app\";\nimport { Auth, getAuth, initializeAuth } from \"firebase/auth\";\nimport { Firestore, getFirestore } from \"firebase/firestore\";\nimport { FirebaseStorage, getStorage } from \"firebase/storage\";\n\nimport { createHostSessionPersistence, type HostSessionPersistence } from \"./sessionPersistence.js\";\n\nexport interface RemoteHostFirebase {\n app: FirebaseApp;\n auth: Auth;\n firestore: Firestore;\n // Storage carries the full-res attachment bytes the command channel can't (a\n // Firestore command doc caps at ~1 MiB). The host, signed in as the user,\n // pulls each staged upload from `users/{uid}/uploads/{id}` and deletes it\n // after ingest.\n storage: FirebaseStorage;\n}\n\nexport const createRemoteHostFirebase = (config: FirebaseOptions): RemoteHostFirebase => {\n const app = initializeApp(config);\n return { app, auth: getAuth(app), firestore: getFirestore(app), storage: getStorage(app) };\n};\n\nexport interface RemoteHostSessionHandles {\n auth: Auth;\n firestore: Firestore;\n storage: FirebaseStorage;\n // The signed-in uid restored from a seed blob, or null when opened fresh\n // (before `signInHost`) or when the blob held no valid session.\n uid: string | null;\n}\n\n// A restartable Firebase session for a host, backed by the export/seed-able\n// persistence (mulmoserver#50, \"case A'\"). Because `initializeAuth` reads\n// persistence once and can run only once per app, each `open` spins up a FRESH\n// app (unique name) with the persistence seeded first, then tears down the\n// previous app — so a reconnect can restore a browser-parked session, and a\n// fresh connect starts clean. `exportSession`/`onSessionChange` expose the blob\n// the browser stores and the signal to re-sync it.\n// Runs on the freshly opened handles BEFORE the previous session is torn down.\n// Reject to keep the open non-destructive: the fresh app is rolled back and the\n// previous session is preserved (so a failed sign-in or an expired blob doesn't\n// replace a healthy live session).\nexport type RemoteHostSessionValidate = (handles: RemoteHostSessionHandles) => Promise<void>;\n\nexport interface RemoteHostSession {\n open: (seedBlob?: string, validate?: RemoteHostSessionValidate) => Promise<RemoteHostSessionHandles>;\n close: () => Promise<void>;\n exportSession: () => string | null;\n onSessionChange: (cb: (blob: string | null) => void) => () => void;\n}\n\nconst noop = (): void => undefined;\n\n// Run operations one-at-a-time in submission order. `open`/`close` mutate shared\n// store/app state, so overlapping calls must not interleave (they would leak an\n// app or leave `app` pointing at a torn-down instance). Mirrors the lifecycle's\n// serialization; a failed op doesn't block the next.\nconst makeSerializer = () => {\n let transition: Promise<unknown> = Promise.resolve();\n return <T>(operation: () => Promise<T>): Promise<T> => {\n const next = transition.then(operation, operation);\n transition = next.then(noop, noop);\n return next;\n };\n};\n\n// Build a fresh app from the (already seeded) persistence and wait for the\n// restored auth state to settle. Persistence restore is async, so `uid`\n// reflects a seeded session only after `authStateReady()` (null when the blob\n// was empty/invalid, or on a fresh connect before `signInHost`).\nconst openFreshApp = async (\n config: FirebaseOptions,\n store: HostSessionPersistence,\n name: string,\n): Promise<{ app: FirebaseApp; handles: RemoteHostSessionHandles }> => {\n // `initializeApp` registers the app in the SDK's global registry, so if init\n // then throws we must delete it — otherwise repeated reconnect failures leak\n // a registered app each time.\n const app = initializeApp(config, name);\n try {\n const auth = initializeAuth(app, { persistence: store.persistence });\n await auth.authStateReady();\n return { app, handles: { auth, firestore: getFirestore(app), storage: getStorage(app), uid: auth.currentUser?.uid ?? null } };\n } catch (error) {\n await deleteApp(app).catch(() => undefined);\n throw error;\n }\n};\n\nexport const createRemoteHostSession = (config: FirebaseOptions): RemoteHostSession => {\n const store = createHostSessionPersistence();\n let app: FirebaseApp | null = null;\n let appSeq = 0;\n const serialize = makeSerializer();\n\n const closeInner = async (): Promise<void> => {\n const previous = app;\n app = null;\n store.clear();\n if (previous) await deleteApp(previous);\n };\n\n // Run the caller's check on the fresh handles; on rejection, delete the fresh\n // app so only the caller-visible error escapes and the previous session (never\n // touched here) stays live.\n const validateOrRollback = async (nextApp: FirebaseApp, handles: RemoteHostSessionHandles, validate?: RemoteHostSessionValidate): Promise<void> => {\n if (!validate) return;\n try {\n await validate(handles);\n } catch (error) {\n await deleteApp(nextApp).catch(() => undefined);\n throw error;\n }\n };\n\n const openInner = async (seedBlob?: string, validate?: RemoteHostSessionValidate): Promise<RemoteHostSessionHandles> => {\n // Non-destructive: keep the current session intact until the fresh app is\n // proven to come up AND pass `validate` (sign-in / uid). A bad seed blob, a\n // failed init, or a rejected validation must not tear down a healthy session\n // — the reconnect contract (mulmoserver#50). So we tear the previous app\n // down only AFTER success, and roll the store back on failure.\n const previousApp = app;\n const previousBlob = store.exportBlob();\n appSeq += 1;\n try {\n store.clear();\n if (seedBlob) store.seed(seedBlob);\n const { app: nextApp, handles } = await openFreshApp(config, store, `remote-host-${appSeq}`);\n await validateOrRollback(nextApp, handles, validate);\n app = nextApp;\n if (previousApp) await deleteApp(previousApp).catch(() => undefined);\n return handles;\n } catch (error) {\n store.clear();\n if (previousBlob) store.seed(previousBlob);\n throw error;\n }\n };\n\n return {\n open: (seedBlob?: string, validate?: RemoteHostSessionValidate) => serialize(() => openInner(seedBlob, validate)),\n close: () => serialize(closeInner),\n exportSession: store.exportBlob,\n onSessionChange: store.onChange,\n };\n};\n"],"mappings":";;;;;;;;AAuBA,IAAM,uBAAuB;AAgC7B,IAAM,eAAa,KAAA;AAGnB,IAAM,cAAc,KAAwB,MAAc,aAAA,GAAA,mBAAA,UAAA,CAC9C,KAAK;CAAE,QAAQ;CAAS,OAAO;EAAE;EAAM;CAAQ;CAAG,YAAA,GAAA,mBAAA,gBAAA,CAA2B;AAAE,CAAC,CAAC,CAAC,MAAM,MAAI;AAIxG,IAAM,gBAAgB,WAAsB,SAAA,GAAA,mBAAA,eAAA,CAC3B,WAAW,OAAO,QAAQ;CACvC,MAAM,QAAQ,MAAM,IAAI,IAAI,GAAG,EAAA,CAAG,KAAK;CACvC,IAAI,CAAC,QAAQ,KAAK,WAAW,UAC3B,OAAO;CAET,IAAI,OAAO,KAAK;EAAE,QAAQ;EAAc,YAAA,GAAA,mBAAA,gBAAA,CAA2B;CAAE,CAAC;CACtE,OAAO;EAAE,QAAQ,KAAK;EAAQ,QAAQ,KAAK,UAAU,CAAC;CAAE;AAC1D,CAAC;AAEH,IAAM,aAAa,OAAO,KAAwB,OAAc,YAAgD;CAC9G,IAAI;EAEF,OAAA,GAAA,mBAAA,UAAA,CAAgB,KAAK;GAAE,QAAQ;GAAQ,QAAQ,MAD1B,QAAQ,MAAM,MAAM,KACgB;GAAM,YAAA,GAAA,mBAAA,gBAAA,CAA2B;EAAE,CAAC;EAC7F,OAAO;GAAE,OAAO;GAAQ,QAAQ,MAAM;EAAO;CAC/C,SAAS,OAAO;EACd,MAAM,UAAU,qBAAA,aAAa,KAAK;EAClC,MAAM,WAAW,KAAK,iBAAiB,OAAO;EAC9C,OAAO;GAAE,OAAO;GAAS,QAAQ,MAAM;GAAQ;EAAQ;CACzD;AACF;AAOA,IAAM,gBAAgB,OAAO,KAAwB,SAAkB,SAA4B,QAAgB;CACjH,IAAI;EACF,MAAM,QAAQ,WAAW,SAAS,GAAG;CACvC,SAAS,OAAO;EACd,QAAQ,UAAU;GAAE,OAAO;GAAS,QAAQ,QAAQ;GAAQ,SAAS,oBAAoB,qBAAA,aAAa,KAAK;EAAI,CAAC;CAClH;CAIA,OAAA,GAAA,mBAAA,UAAA,CAAgB,GAAG,CAAC,CAAC,OAAO,UAAU;EACpC,QAAQ,UAAU;GAAE,OAAO;GAAS,QAAQ,QAAQ;GAAQ,SAAS,yBAAyB,qBAAA,aAAa,KAAK;EAAI,CAAC;CACvH,CAAC;CACD,QAAQ,UAAU;EAAE,OAAO;EAAQ,QAAQ,QAAQ;EAAQ,SAAS;CAAU,CAAC;AACjF;AAYA,IAAM,iBAAiB,OAAO,KAAoB,KAAwB,SAAkB,QAAgB;CAC1G,MAAM,EAAE,UAAU,YAAY;CAE9B,IAAI,0BAAA,UAAU,SAAS,GAAG,GAAG;EAC3B,MAAM,cAAc,KAAK,SAAS,SAAS,IAAI,GAAG;EAClD;CACF;CACA,MAAM,QAAQ,MAAM,aAAa,IAAI,WAAW,GAAG;CACnD,IAAI,CAAC,OACH;CAEF,QAAQ,UAAU;EAAE,OAAO;EAAY,QAAQ,MAAM;CAAO,CAAC;CAC7D,MAAM,UAAsC,SAAS,MAAM;CAC3D,IAAI,CAAC,SAAS;EACZ,MAAM,WAAW,KAAK,kBAAkB,0BAA0B,MAAM,QAAQ;EAChF,QAAQ,UAAU;GAAE,OAAO;GAAS,QAAQ,MAAM;GAAQ,SAAS;EAAiB,CAAC;EACrF;CACF;CACA,QAAQ,UAAU,MAAM,WAAW,KAAK,OAAO,OAAO,CAAC;AACzD;AAMA,IAAa,mBAAmB,WAAsB,SAAkB,UAA2B,UAA6B,CAAC,MAAoB;CACnJ,MAAM,WAAW,0BAAA,QAAQ,WAAW,OAAO;CAO3C,MAAM,iBAAiB,WAA0B;EAC/C,CAAA,GAAA,mBAAA,OAAA,CAAO,UAAU;GAAE,GAAG,0BAAA,kBAAkB,SAAS,UAAU,MAAM;GAAG,YAAA,GAAA,mBAAA,gBAAA,CAA2B;EAAE,CAAC,CAAC,CAAC,MAAM,MAAI;CAChH;CACA,MAAM,iBAAiB;EACrB,cAAc,IAAI;CACpB;CACA,SAAS;CACT,MAAM,OAAO,YAAY,UAAU,QAAQ,eAAe,oBAAoB;CAE9E,MAAM,kBAAA,GAAA,mBAAA,MAAA,CAAuB,0BAAA,mBAAmB,WAAW,OAAO,IAAA,GAAA,mBAAA,MAAA,CAAS,UAAU,MAAM,QAAQ,CAAC;CACpG,MAAM,MAAqB;EAAE;EAAW;EAAU;EAAS,KAAK,QAAQ;CAAI;CAC5E,MAAM,eAAA,GAAA,mBAAA,WAAA,CACJ,iBACC,aAAa;EACZ,MAAM,MAAM,KAAK,IAAI;EAarB,SAJG,WAAW,CAAC,CACZ,QAAQ,WAAW,OAAO,SAAS,OAAO,CAAC,CAC3C,KAAK,YAAY;GAAE,KAAK,OAAO,IAAI;GAAK,SAAS,OAAO,IAAI,KAAK;EAAa,EAAE,CAAC,CACjF,MAAM,MAAM,UAAU,0BAAA,YAAY,KAAK,SAAS,MAAM,OAAO,CAChE,CAAA,CAAM,SAAS,EAAE,KAAK,cAAc;GAClC,eAAe,KAAK,KAAK,SAAS,GAAG,CAAC,CAAC,MAAM,MAAI;EACnD,CAAC;CACH,IACC,UAAU;EACT,QAAQ,UAAU;GAAE,OAAO;GAAS,QAAQ;GAAU,SAAS,MAAM;EAAQ,CAAC;EAK9E,cAAc,IAAI;EAClB,cAAc,KAAK;EACnB,QAAQ,WAAW;CACrB,CACF;CAEA,aAAa;EACX,cAAc,IAAI;EAClB,cAAc,KAAK;EACnB,YAAY;CACd;AACF;;;ACrIA,IAAM,eAAa,KAAA;AACnB,IAAM,eAAiC;CAAE,MAAM;CAAM,MAAM;CAAM,OAAO;AAAK;AAE7E,IAAa,oBAAoB,SAA8C;CAC7E,MAAM,MAAM,KAAK,OAAO;CAIxB,IAAI,aAAkC;CAMtC,IAAI,aAA+B,QAAQ,QAAQ;CAEnD,MAAM,aAAgB,cAA4C;EAChE,MAAM,OAAO,WAAW,KAAK,WAAW,SAAS;EACjD,aAAa,KAAK,KAAK,QAAM,MAAI;EACjC,OAAO;CACT;CAEA,MAAM,sBAAsB;EAC1B,IAAI,YAAY;GACd,WAAW;GACX,aAAa;EACf;CACF;CAEA,MAAM,gBAAkC;EAAE,WAAW,eAAe;EAAM,KAAK,KAAK,WAAW;CAAE;CAIjG,MAAM,UAAU,KAAa,SAAmC;EAC9D,cAAc;EACd,MAAM,SAAS,KAAK,YAAY;GAAE;GAAK,QAAQ,KAAK;EAAO,GAAG,KAAK,UAAU;GAC3E,UAAU,UAAU,IAAI,MAAM,eAAe,MAAM,MAAM,GAAG,MAAM,QAAQ;GAC1E,UAAU,KAAK;GAIf,gBAAgB;IACd,IAAI,eAAe,QAAQ;KACzB,aAAa;KACb,IAAI,KAAK,gDAAgD;IAC3D;GACF;EACF,CAAC;EACD,aAAa;EACb,IAAI,KAAK,GAAG,KAAK,MAAM,IAAI,gCAAgC,KAAK,OAAO,EAAE;EACzE,OAAO,OAAO;CAChB;CAEA,MAAM,WAAW,YAA+C,UAAU,YAAY,OAAO,MAAM,KAAK,OAAO,OAAO,GAAG,WAAW,CAAC;CAErI,MAAM,aAAa,gBACjB,UAAU,YAAY;EACpB,MAAM,EAAE,YAAY;EACpB,IAAI,CAAC,SAAS,MAAM,IAAI,MAAM,uDAAuD;EACrF,OAAO,OAAO,MAAM,QAAQ,WAAW,GAAG,aAAa;CACzD,CAAC;CAEH,MAAM,mBACJ,UAAU,YAAY;EACpB,cAAc;EACd,MAAM,KAAK,QAAQ;EACnB,IAAI,KAAK,mCAAmC;EAC5C,OAAO,OAAO;CAChB,CAAC;CAEH,OAAO;EAAE;EAAS;EAAW;EAAY;CAAO;AAClD;;;AC9GA,IAAa,wBAAwB,UAAgC;CACnE,YAAY,OAAO,YAAqC;EAGtD,QAAO,OAAA,GAAA,cAAA,qBAAA,CAD2C,MAD/B,cAAA,mBAAmB,WAAW,OACO,CAAU,EAAA,CAC5C,KAAK;CAC7B;CACA,oBAAA,GAAA,cAAA,QAAA,CAA0C,IAAI;CAC9C,kBAAiC,KAAK,aAAa,OAAO;AAC5D;;;AC0BA,IAAM,YAAY,UAAqD,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAE1I,IAAM,sBAAsB,UAA8C,OAAO,UAAU,YAAY,SAAS,KAAK;AAMrH,IAAa,kBAAkB,SAA0B;CACvD,IAAI;EACF,OAAO,SAAS,KAAK,MAAM,IAAI,CAAC;CAClC,QAAQ;EACN,OAAO;CACT;AACF;AAOA,IAAM,4BAA4B,OAAsC,WAAiD;CACvH,MAAM,oBAAoB;EACxB,OAAgB,OAAO;EACvB,OAAgB;EAChB,QAAyB;EACzB,SAA0B;EAC1B,2BAA4B,IAAI,IAAqB;EACrD,eAAiC;GAC/B,OAAO,QAAQ,QAAQ,KAAK,iBAAiB,GAAG;EAClD;EACA,MAAM,KAAK,KAAa,OAAwC;GAC9D,KAAK,MAAM,IAAI,KAAK,KAAK;GACzB,KAAK,OAAO;EACd;EACA,KAAK,KAA+C;GAClD,OAAO,QAAQ,QAAQ,KAAK,MAAM,IAAI,GAAG,KAAK,IAAI;EACpD;EACA,MAAM,QAAQ,KAA4B;GACxC,KAAK,MAAM,OAAO,GAAG;GACrB,KAAK,OAAO;EACd;EAGA,aAAa,MAAc,UAAiC;GAC1D,KAAK,SAAS,IAAI,QAAQ;EAC5B;EACA,gBAAgB,MAAc,UAAiC;GAC7D,KAAK,SAAS,OAAO,QAAQ;EAC/B;CACF;CACA,OAAO;AACT;AAEA,IAAa,qCAA6D;CACxE,MAAM,wBAAQ,IAAI,IAA8B;CAChD,MAAM,4BAAY,IAAI,IAAmC;CAEzD,MAAM,mBAAmC,MAAM,SAAS,IAAI,OAAO,KAAK,UAAU,OAAO,YAAY,KAAK,CAAC;CAE3G,MAAM,eAAqB;EACzB,MAAM,OAAO,WAAW;EACxB,UAAU,SAAS,aAAa,SAAS,IAAI,CAAC;CAChD;CAEA,MAAM,cAAc,yBAAyB,OAAO,MAAM;CAE1D,MAAM,QAAQ,SAAuB;EACnC,MAAM,SAAkB,KAAK,MAAM,IAAI;EACvC,IAAI,CAAC,SAAS,MAAM,GAAG,MAAM,IAAI,MAAM,yCAAyC;EAChF,MAAM,MAAM;EACZ,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,MAAM,GAC9C,IAAI,mBAAmB,KAAK,GAAG,MAAM,IAAI,KAAK,KAAK;CAEvD;CAEA,MAAM,YAAY,aAA0D;EAC1E,UAAU,IAAI,QAAQ;EACtB,aAAa,UAAU,OAAO,QAAQ;CACxC;CAEA,MAAM,cAAoB;EACxB,MAAM,MAAM;CACd;CAEA,OAAO;EAAE;EAAa;EAAM;EAAY;EAAU;CAAM;AAC1D;;;ACnHA,IAAa,4BAA4B,WAAgD;CACvF,MAAM,OAAA,GAAA,aAAA,cAAA,CAAoB,MAAM;CAChC,OAAO;EAAE;EAAK,OAAA,GAAA,cAAA,QAAA,CAAc,GAAG;EAAG,YAAA,GAAA,mBAAA,aAAA,CAAwB,GAAG;EAAG,UAAA,GAAA,iBAAA,WAAA,CAAoB,GAAG;CAAE;AAC3F;AA+BA,IAAM,aAAmB,KAAA;AAMzB,IAAM,uBAAuB;CAC3B,IAAI,aAA+B,QAAQ,QAAQ;CACnD,QAAW,cAA4C;EACrD,MAAM,OAAO,WAAW,KAAK,WAAW,SAAS;EACjD,aAAa,KAAK,KAAK,MAAM,IAAI;EACjC,OAAO;CACT;AACF;AAMA,IAAM,eAAe,OACnB,QACA,OACA,SACqE;CAIrE,MAAM,OAAA,GAAA,aAAA,cAAA,CAAoB,QAAQ,IAAI;CACtC,IAAI;EACF,MAAM,QAAA,GAAA,cAAA,eAAA,CAAsB,KAAK,EAAE,aAAa,MAAM,YAAY,CAAC;EACnE,MAAM,KAAK,eAAe;EAC1B,OAAO;GAAE;GAAK,SAAS;IAAE;IAAM,YAAA,GAAA,mBAAA,aAAA,CAAwB,GAAG;IAAG,UAAA,GAAA,iBAAA,WAAA,CAAoB,GAAG;IAAG,KAAK,KAAK,aAAa,OAAO;GAAK;EAAE;CAC9H,SAAS,OAAO;EACd,OAAA,GAAA,aAAA,UAAA,CAAgB,GAAG,CAAC,CAAC,YAAY,KAAA,CAAS;EAC1C,MAAM;CACR;AACF;AAEA,IAAa,2BAA2B,WAA+C;CACrF,MAAM,QAAQ,6BAA6B;CAC3C,IAAI,MAA0B;CAC9B,IAAI,SAAS;CACb,MAAM,YAAY,eAAe;CAEjC,MAAM,aAAa,YAA2B;EAC5C,MAAM,WAAW;EACjB,MAAM;EACN,MAAM,MAAM;EACZ,IAAI,UAAU,OAAA,GAAA,aAAA,UAAA,CAAgB,QAAQ;CACxC;CAKA,MAAM,qBAAqB,OAAO,SAAsB,SAAmC,aAAwD;EACjJ,IAAI,CAAC,UAAU;EACf,IAAI;GACF,MAAM,SAAS,OAAO;EACxB,SAAS,OAAO;GACd,OAAA,GAAA,aAAA,UAAA,CAAgB,OAAO,CAAC,CAAC,YAAY,KAAA,CAAS;GAC9C,MAAM;EACR;CACF;CAEA,MAAM,YAAY,OAAO,UAAmB,aAA4E;EAMtH,MAAM,cAAc;EACpB,MAAM,eAAe,MAAM,WAAW;EACtC,UAAU;EACV,IAAI;GACF,MAAM,MAAM;GACZ,IAAI,UAAU,MAAM,KAAK,QAAQ;GACjC,MAAM,EAAE,KAAK,SAAS,YAAY,MAAM,aAAa,QAAQ,OAAO,eAAe,QAAQ;GAC3F,MAAM,mBAAmB,SAAS,SAAS,QAAQ;GACnD,MAAM;GACN,IAAI,aAAa,OAAA,GAAA,aAAA,UAAA,CAAgB,WAAW,CAAC,CAAC,YAAY,KAAA,CAAS;GACnE,OAAO;EACT,SAAS,OAAO;GACd,MAAM,MAAM;GACZ,IAAI,cAAc,MAAM,KAAK,YAAY;GACzC,MAAM;EACR;CACF;CAEA,OAAO;EACL,OAAO,UAAmB,aAAyC,gBAAgB,UAAU,UAAU,QAAQ,CAAC;EAChH,aAAa,UAAU,UAAU;EACjC,eAAe,MAAM;EACrB,iBAAiB,MAAM;CACzB;AACF"}
@@ -97,10 +97,12 @@ var processCommand = async (ctx, ref, command, now) => {
97
97
  };
98
98
  var startHostRunner = (firestore, channel, handlers, options = {}) => {
99
99
  const presence = hostDoc(firestore, channel);
100
- const writePresence = (online) => setDoc(presence, {
101
- ...buildHostPresence(channel, handlers, online),
102
- updatedAt: serverTimestamp()
103
- }).catch(noop$2);
100
+ const writePresence = (online) => {
101
+ setDoc(presence, {
102
+ ...buildHostPresence(channel, handlers, online),
103
+ updatedAt: serverTimestamp()
104
+ }).catch(noop$2);
105
+ };
104
106
  const announce = () => {
105
107
  writePresence(true);
106
108
  };
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../../src/remote-host/server/hostRunner.ts","../../../src/remote-host/server/lifecycle.ts","../../../src/remote-host/server/auth.ts","../../../src/remote-host/server/sessionPersistence.ts","../../../src/remote-host/server/firebase.ts"],"sourcesContent":["// Host side of the command channel: claim queued commands, run handlers, write\n// results back, and announce presence via heartbeat.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/hostRunner.ts (itself\n// ported from ../mulmoserver). The only signature change vs. that copy: the\n// `firestore` instance is a parameter (each host supplies its own Firebase init),\n// and the heartbeat interval is an option (defaults to one minute).\nimport { DocumentReference, Firestore, deleteDoc, onSnapshot, query, runTransaction, serverTimestamp, setDoc, updateDoc, where } from \"firebase/firestore\";\n\nimport { errorMessage } from \"../../collection/core/errorMessage.js\";\nimport {\n Channel,\n Command,\n CommandHandler,\n CommandHandlers,\n JsonObject,\n buildHostPresence,\n byCreatedAt,\n commandsCollection,\n hostDoc,\n isExpired,\n} from \"../index.js\";\n\nconst DEFAULT_HEARTBEAT_MS = 60_000;\n\nexport interface HostEvent {\n phase: \"received\" | \"done\" | \"error\";\n method: string;\n message?: string;\n}\n\nexport interface HostRunnerOptions {\n onEvent?: (event: HostEvent) => void;\n // Called once when the listener dies fatally (after presence has been set\n // offline), so the lifecycle owner can reconcile its own state — e.g. clear\n // the runner handle so status() no longer reports connected. NOT called on a\n // normal stop().\n onClosed?: () => void;\n // Called when a command is dropped for being past its `expiresAt`, BEFORE the\n // doc is deleted, so the host can clean up out-of-band resources the command\n // referenced (e.g. staged attachment uploads in Storage). `uid` is THIS runner's\n // session uid (channel.uid) — passed in rather than read from a global so a\n // concurrent reconnect as a different account can't point cleanup at the wrong\n // user's Storage path. Best-effort: a throw is logged via onEvent and does NOT\n // block the doc deletion. Absent ⇒ the expired doc is simply deleted.\n onExpire?: (command: Command, uid: string) => void | Promise<void>;\n // Presence heartbeat interval; defaults to one minute.\n heartbeatMs?: number;\n}\n\ninterface Claim {\n method: string;\n params: JsonObject;\n}\n\nconst noop = () => undefined;\n\n// The remote may have deleted the doc on timeout, so ignore write-after-delete.\nconst writeError = (ref: DocumentReference, code: string, message: string) =>\n updateDoc(ref, { status: \"error\", error: { code, message }, updatedAt: serverTimestamp() }).catch(noop);\n\n// Atomically move a command queued -> processing so it is handled exactly once.\n// Returns the method/params to run, or null if another handler already took it.\nconst claimCommand = (firestore: Firestore, ref: DocumentReference): Promise<Claim | null> =>\n runTransaction(firestore, async (txn) => {\n const data = (await txn.get(ref)).data() as Command | undefined;\n if (!data || data.status !== \"queued\") {\n return null;\n }\n txn.update(ref, { status: \"processing\", updatedAt: serverTimestamp() });\n return { method: data.method, params: data.params ?? {} };\n });\n\nconst runHandler = async (ref: DocumentReference, claim: Claim, handler: CommandHandler): Promise<HostEvent> => {\n try {\n const result = await handler(claim.params);\n await updateDoc(ref, { status: \"done\", result: result ?? null, updatedAt: serverTimestamp() });\n return { phase: \"done\", method: claim.method };\n } catch (error) {\n const message = errorMessage(error);\n await writeError(ref, \"handler_error\", message);\n return { phase: \"error\", method: claim.method, message };\n }\n};\n\n// A command past its deadline is removed entirely rather than run: give the host\n// a chance to clean up out-of-band resources (staged attachments), then delete\n// the doc so it is neither reprocessed nor left as a stale error. Both steps are\n// best-effort/idempotent, so a snapshot replay surfacing the same expired doc\n// twice is harmless (no claim transaction needed — see plan edge #3).\nconst expireCommand = async (ref: DocumentReference, command: Command, options: HostRunnerOptions, uid: string) => {\n try {\n await options.onExpire?.(command, uid);\n } catch (error) {\n options.onEvent?.({ phase: \"error\", method: command.method, message: `onExpire failed: ${errorMessage(error)}` });\n }\n // Surface a delete failure (permissions / transient network) the same way the\n // onExpire failure above is surfaced — otherwise the expired doc lingers as\n // \"queued\" with no signal as to why cleanup didn't happen.\n await deleteDoc(ref).catch((error) => {\n options.onEvent?.({ phase: \"error\", method: command.method, message: `expire delete failed: ${errorMessage(error)}` });\n });\n options.onEvent?.({ phase: \"done\", method: command.method, message: \"expired\" });\n};\n\n// Per-runner constants bundled into one context so processCommand stays under the\n// max-params cap: firestore, the handler table, options, and the session uid are\n// all fixed for the runner's lifetime; only ref/command/now vary per command.\ninterface RunnerContext {\n firestore: Firestore;\n handlers: CommandHandlers;\n options: HostRunnerOptions;\n uid: string;\n}\n\nconst processCommand = async (ctx: RunnerContext, ref: DocumentReference, command: Command, now: number) => {\n const { handlers, options } = ctx;\n // Drop an expired command before claiming it — it must never reach a handler.\n if (isExpired(command, now)) {\n await expireCommand(ref, command, options, ctx.uid);\n return;\n }\n const claim = await claimCommand(ctx.firestore, ref);\n if (!claim) {\n return;\n }\n options.onEvent?.({ phase: \"received\", method: claim.method });\n const handler: CommandHandler | undefined = handlers[claim.method];\n if (!handler) {\n await writeError(ref, \"unknown_method\", `No handler for method: ${claim.method}`);\n options.onEvent?.({ phase: \"error\", method: claim.method, message: \"unknown method\" });\n return;\n }\n options.onEvent?.(await runHandler(ref, claim, handler));\n};\n\n// startHostRunner subscribes to queued commands for the given channel and runs\n// each one through the supplied handler table. It also announces presence (a\n// heartbeat on users/{uid}/hosts/{hostId}) so the remote can tell it is online.\n// Returns a stop function that goes offline and detaches the listener.\nexport const startHostRunner = (firestore: Firestore, channel: Channel, handlers: CommandHandlers, options: HostRunnerOptions = {}): (() => void) => {\n const presence = hostDoc(firestore, channel);\n // Advertise online/offline + the capability set (method names + protocol\n // version) on the same doc the remote already listens to for presence.\n const writePresence = (online: boolean) => setDoc(presence, { ...buildHostPresence(channel, handlers, online), updatedAt: serverTimestamp() }).catch(noop);\n const announce = () => {\n writePresence(true);\n };\n announce();\n const beat = setInterval(announce, options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS);\n\n const queuedCommands = query(commandsCollection(firestore, channel), where(\"status\", \"==\", \"queued\"));\n const ctx: RunnerContext = { firestore, handlers, options, uid: channel.uid };\n const unsubscribe = onSnapshot(\n queuedCommands,\n (snapshot) => {\n const now = Date.now();\n // Best-effort oldest-first DISPATCH only. Commands are processed\n // concurrently (not awaited in turn) and out-of-order completion is fine by\n // design — chat is asynchronous — so this sort just biases which command\n // starts first; it is not an ordering guarantee. We still sort in memory\n // rather than orderBy(\"createdAt\") on the query because a Firestore orderBy\n // silently EXCLUDES docs missing the field — which would drop every\n // pre-offline-queue command (no createdAt) from the queue entirely.\n const added = snapshot\n .docChanges()\n .filter((change) => change.type === \"added\")\n .map((change) => ({ ref: change.doc.ref, command: change.doc.data() as Command }))\n .sort((left, right) => byCreatedAt(left.command, right.command));\n added.forEach(({ ref, command }) => {\n processCommand(ctx, ref, command, now).catch(noop);\n });\n },\n (error) => {\n options.onEvent?.({ phase: \"error\", method: \"listen\", message: error.message });\n // A Firestore onSnapshot error terminates the listener and it does not\n // recover on its own. Stop advertising presence (clear the heartbeat +\n // write online:false) so remotes see the host as offline instead of a\n // live host that silently consumes no commands.\n clearInterval(beat);\n writePresence(false);\n options.onClosed?.();\n },\n );\n\n return () => {\n clearInterval(beat);\n writePresence(false);\n unsubscribe();\n };\n};\n","// Remote-host lifecycle: wire a Firebase session to the Firestore host runner so\n// connecting starts the command loop + presence heartbeat, and disconnecting\n// stops both.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/index.ts. The factory\n// takes injected collaborators — real hosts bind them to Firebase; tests pass\n// fakes to exercise the invariants (non-destructive connect, serialized\n// transitions, status/liveness reconciliation on fatal listener death) without a\n// network. `hostId` and the logger are injected too, so this file imports no\n// Firebase and no host logger and stays trivially unit-testable.\n//\n// Single-account, single-host per instance, in-memory session: a host restart\n// drops the session and needs a re-connect.\nimport type { Channel, Command, CommandHandlers } from \"../index.js\";\nimport type { HostRunnerOptions } from \"./hostRunner.js\";\n\nexport interface RemoteHostStatus {\n connected: boolean;\n uid: string | null;\n}\n\n// Minimal logger the factory calls; each host adapts its own logger to this\n// shape (or omits it to run silently, as the tests do).\nexport interface RemoteHostLogger {\n info: (msg: string) => void;\n warn: (msg: string) => void;\n debug: (msg: string) => void;\n}\n\n// Injectable collaborators — a real host binds these to Firebase + its own\n// hostId; tests pass fakes to exercise the lifecycle without a network.\n// `startRunner` is the host's `startHostRunner` pre-bound with its Firestore\n// instance, so this module needs no Firebase of its own.\nexport interface RemoteHostDeps {\n hostId: string;\n signIn: (idToken: string) => Promise<string>;\n // Restore a session from a browser-parked blob (case A', mulmoserver#50) and\n // resolve to its uid — no popup. MUST reject when the blob yields no valid\n // session so `reconnect` stays non-destructive. Absent ⇒ `reconnect` throws.\n restore?: (sessionBlob: string) => Promise<string>;\n signOut: () => Promise<void>;\n currentUid: () => string | null;\n startRunner: (channel: Channel, handlers: CommandHandlers, options: HostRunnerOptions) => () => void;\n handlers: CommandHandlers;\n // Optional host-specific cleanup for a command the runner drops as expired\n // (e.g. delete its staged attachment uploads). Threaded verbatim into the\n // runner's `onExpire`, which supplies the session `uid` so cleanup targets the\n // right user's Storage path even across a reconnect; absent ⇒ an expired doc is\n // just deleted.\n onExpire?: (command: Command, uid: string) => void | Promise<void>;\n log?: RemoteHostLogger;\n}\n\nexport interface RemoteHostLifecycle {\n connect: (idToken: string) => Promise<RemoteHostStatus>;\n // Restore a browser-parked session and start the runner — popup-free re-attach\n // after a host restart. Non-destructive like `connect`. Rejects if `deps.restore`\n // isn't wired, or if restore fails (blob expired/invalid → caller falls back to connect).\n reconnect: (sessionBlob: string) => Promise<RemoteHostStatus>;\n disconnect: () => Promise<RemoteHostStatus>;\n status: () => RemoteHostStatus;\n}\n\nconst noop = () => undefined;\nconst silentLogger: RemoteHostLogger = { info: noop, warn: noop, debug: noop };\n\nexport const createRemoteHost = (deps: RemoteHostDeps): RemoteHostLifecycle => {\n const log = deps.log ?? silentLogger;\n\n // The running host runner's stop() handle, or null when disconnected. Keeps\n // the single-host invariant — one runner at a time.\n let stopRunner: (() => void) | null = null;\n\n // Serialize connect/disconnect so overlapping requests can't both mutate\n // stopRunner (which would leak a second runner) or race auth against teardown.\n // Each transition runs only after the previous one settles; a failed\n // transition does not block the next (both handlers run the next op).\n let transition: Promise<unknown> = Promise.resolve();\n\n const serialize = <T>(operation: () => Promise<T>): Promise<T> => {\n const next = transition.then(operation, operation);\n transition = next.then(noop, noop);\n return next;\n };\n\n const stopIfRunning = () => {\n if (stopRunner) {\n stopRunner();\n stopRunner = null;\n }\n };\n\n const status = (): RemoteHostStatus => ({ connected: stopRunner !== null, uid: deps.currentUid() });\n\n // Swap the running runner for one bound to `uid`. Callers authenticate FIRST\n // (before this teardown) so a failed connect/reconnect keeps a healthy session.\n const attach = (uid: string, verb: string): RemoteHostStatus => {\n stopIfRunning();\n const runner = deps.startRunner({ uid, hostId: deps.hostId }, deps.handlers, {\n onEvent: (event) => log.debug(`host event: ${event.phase} ${event.method}`),\n onExpire: deps.onExpire,\n // Fatal listener death: drop the handle so status() stops reporting\n // connected — but only if it still points at THIS runner (a later\n // reconnect may have replaced it).\n onClosed: () => {\n if (stopRunner === runner) {\n stopRunner = null;\n log.warn(\"host runner listener died; marked disconnected\");\n }\n },\n });\n stopRunner = runner;\n log.info(`${verb} as ${uid}, host runner started (hostId=${deps.hostId})`);\n return status();\n };\n\n const connect = (idToken: string): Promise<RemoteHostStatus> => serialize(async () => attach(await deps.signIn(idToken), \"connected\"));\n\n const reconnect = (sessionBlob: string): Promise<RemoteHostStatus> =>\n serialize(async () => {\n const { restore } = deps;\n if (!restore) throw new Error(\"remote-host reconnect requires a `restore` dependency\");\n return attach(await restore(sessionBlob), \"reconnected\");\n });\n\n const disconnect = (): Promise<RemoteHostStatus> =>\n serialize(async () => {\n stopIfRunning();\n await deps.signOut();\n log.info(\"disconnected, host runner stopped\");\n return status();\n });\n\n return { connect, reconnect, disconnect, status };\n};\n","// Firebase credential exchange for the remote-host runner.\n//\n// The host authenticates to Firestore *as the user* (Option B) using the\n// Firebase JS SDK's signInWithCredential with a browser-minted Google OAuth ID\n// token — no Admin SDK, no project service account. Security rules keep the host\n// scoped to that user's own users/{uid}/… subtree.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/auth.ts. The `auth`\n// instance is a parameter so each host binds its own Firebase init; the factory\n// returns the low-level credential primitives (the connect/disconnect lifecycle\n// that starts/stops the host runner lives in createRemoteHost).\nimport { Auth, GoogleAuthProvider, signInWithCredential, signOut } from \"firebase/auth\";\n\nexport interface RemoteHostAuth {\n // Establish the Firebase session from a browser-minted Google OAuth ID token.\n // The token is used once here; the JS SDK then holds its own refresh token for\n // the process lifetime. Resolves to the authenticated uid.\n signInHost: (idToken: string) => Promise<string>;\n // Tear down the Firebase session (in-memory persistence → re-login on restart).\n signOutHost: () => Promise<void>;\n // The currently signed-in uid, or null when disconnected.\n currentUid: () => string | null;\n}\n\nexport const createRemoteHostAuth = (auth: Auth): RemoteHostAuth => ({\n signInHost: async (idToken: string): Promise<string> => {\n const credential = GoogleAuthProvider.credential(idToken);\n const userCredential = await signInWithCredential(auth, credential);\n return userCredential.user.uid;\n },\n signOutHost: (): Promise<void> => signOut(auth),\n currentUid: (): string | null => auth.currentUser?.uid ?? null,\n});\n","// A custom Firebase Auth persistence that keeps the signed-in session in memory\n// but can be seeded from — and exported back to — an opaque blob, so a host's\n// Firebase session survives a process restart by being parked in the browser's\n// localStorage (mulmoserver#50, \"case A'\"). The blob is whatever the SDK wrote\n// into persistence, round-tripped through JSON; we NEVER interpret its fields,\n// so we don't couple to the SDK's serialized-user format across versions.\n//\n// **Persistence is a CLASS, not an instance.** `initializeAuth(app, { persistence })`\n// hands the value to the SDK's `_getInstance(cls)`, which asserts `cls instanceof\n// Function` (\"Expected a class definition\") and then `new cls()`. Passing a plain\n// object throws — the SDK's own `inMemoryPersistence` is likewise a class. The\n// class is defined per factory call so its instances share this call's store;\n// `type: \"LOCAL\"` makes the SDK treat it as durable and persist the user.\n//\n// Seed BEFORE `initializeAuth` — the SDK reads persistence once at init. `onChange`\n// fires when the SDK writes/removes a key (a token update that rotates the stored\n// blob, or a sign-out) — the signal to re-sync the browser copy.\nimport type { Persistence } from \"firebase/auth\";\n\n// The SDK stores values as JSON objects (a serialized user) or strings. We treat\n// them opaquely — a value is either.\ntype PersistenceValue = Record<string, unknown> | string;\n\ntype StorageListener = (value: PersistenceValue | null) => void;\n\n// The instance the SDK builds via `new` and then drives with these `_`-methods.\nexport interface HostAuthPersistenceInstance extends Persistence {\n type: \"LOCAL\";\n _isAvailable: () => Promise<boolean>;\n _set: (key: string, value: PersistenceValue) => Promise<void>;\n _get: (key: string) => Promise<PersistenceValue | null>;\n _remove: (key: string) => Promise<void>;\n _addListener: (key: string, listener: StorageListener) => void;\n _removeListener: (key: string, listener: StorageListener) => void;\n}\n\n// The class value passed to `initializeAuth`. The static `type` makes it\n// assignable to the SDK's `Persistence` (a class-valued-typed-as-instance, the\n// same trick the SDK uses for `inMemoryPersistence`); `new ()` lets the SDK — and\n// the tests — instantiate it.\nexport interface HostAuthPersistenceClass extends Persistence {\n type: \"LOCAL\";\n new (): HostAuthPersistenceInstance;\n}\n\nexport interface HostSessionPersistence {\n /** Pass to `initializeAuth(app, { persistence })`. It's a class the SDK `new`s. */\n persistence: HostAuthPersistenceClass;\n /** Load a previously exported blob. Call BEFORE `initializeAuth`. */\n seed: (blob: string) => void;\n /** Serialize the current contents, or `null` when empty (no session). */\n exportBlob: () => string | null;\n /** Notified with the fresh blob (or `null`) whenever the SDK writes/removes. */\n onChange: (listener: (blob: string | null) => void) => () => void;\n /** Drop all contents (used when tearing down the Firebase app). */\n clear: () => void;\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> => typeof value === \"object\" && value !== null && !Array.isArray(value);\n\nconst isPersistenceValue = (value: unknown): value is PersistenceValue => typeof value === \"string\" || isRecord(value);\n\n// True when `blob` is a JSON object `seed` can load. A blob that fails this can\n// never restore a session (corrupt localStorage, wrong shape), so callers treat\n// it as an expired/invalid session rather than a transient error — the client\n// then drops it instead of retrying the same doomed blob forever.\nexport const isSeedableBlob = (blob: string): boolean => {\n try {\n return isRecord(JSON.parse(blob));\n } catch {\n return false;\n }\n};\n\n// A class (constructor) so the SDK's `_getInstance` accepts it (it asserts\n// `cls instanceof Function`, then `new cls()`); instances hold the shared\n// `store`/`notify`, so a fresh `new` still sees the seeded/live data. `static\n// type` is for the `Persistence` type bridge; the instance `type` is what the\n// SDK reads after `new`.\nconst makeHostPersistenceClass = (store: Map<string, PersistenceValue>, notify: () => void): HostAuthPersistenceClass => {\n class HostAuthPersistence {\n static readonly type = \"LOCAL\" as const;\n readonly type = \"LOCAL\" as const;\n private readonly store = store;\n private readonly notify = notify;\n private readonly external = new Set<StorageListener>();\n _isAvailable(): Promise<boolean> {\n return Promise.resolve(this.store instanceof Map);\n }\n async _set(key: string, value: PersistenceValue): Promise<void> {\n this.store.set(key, value);\n this.notify();\n }\n _get(key: string): Promise<PersistenceValue | null> {\n return Promise.resolve(this.store.get(key) ?? null);\n }\n async _remove(key: string): Promise<void> {\n this.store.delete(key);\n this.notify();\n }\n // Node has no cross-tab storage events to deliver, so registered listeners\n // never fire; we still track them so add/remove stay symmetric.\n _addListener(_key: string, listener: StorageListener): void {\n this.external.add(listener);\n }\n _removeListener(_key: string, listener: StorageListener): void {\n this.external.delete(listener);\n }\n }\n return HostAuthPersistence;\n};\n\nexport const createHostSessionPersistence = (): HostSessionPersistence => {\n const store = new Map<string, PersistenceValue>();\n const listeners = new Set<(blob: string | null) => void>();\n\n const exportBlob = (): string | null => (store.size === 0 ? null : JSON.stringify(Object.fromEntries(store)));\n\n const notify = (): void => {\n const blob = exportBlob();\n listeners.forEach((listener) => listener(blob));\n };\n\n const persistence = makeHostPersistenceClass(store, notify);\n\n const seed = (blob: string): void => {\n const parsed: unknown = JSON.parse(blob);\n if (!isRecord(parsed)) throw new Error(\"host session blob must be a JSON object\");\n store.clear();\n for (const [key, value] of Object.entries(parsed)) {\n if (isPersistenceValue(value)) store.set(key, value);\n }\n };\n\n const onChange = (listener: (blob: string | null) => void): (() => void) => {\n listeners.add(listener);\n return () => listeners.delete(listener);\n };\n\n const clear = (): void => {\n store.clear();\n };\n\n return { persistence, seed, exportBlob, onChange, clear };\n};\n","// Firebase init for a remote-host runner.\n//\n// A host acts as a *host*: it signs in to Firebase as the user (via\n// signInWithCredential, see auth.ts) and listens to that user's command queue in\n// Firestore. The modular firebase/firestore + firebase/auth SDKs run in Node, so\n// this mirrors a browser init but also exposes Firestore (default database,\n// which must be in Native mode) and Storage.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/firebase.ts. The\n// public web config is a parameter so each host supplies its own (both hosts\n// reuse the shared mulmoserver project).\nimport { deleteApp, FirebaseApp, FirebaseOptions, initializeApp } from \"firebase/app\";\nimport { Auth, getAuth, initializeAuth } from \"firebase/auth\";\nimport { Firestore, getFirestore } from \"firebase/firestore\";\nimport { FirebaseStorage, getStorage } from \"firebase/storage\";\n\nimport { createHostSessionPersistence, type HostSessionPersistence } from \"./sessionPersistence.js\";\n\nexport interface RemoteHostFirebase {\n app: FirebaseApp;\n auth: Auth;\n firestore: Firestore;\n // Storage carries the full-res attachment bytes the command channel can't (a\n // Firestore command doc caps at ~1 MiB). The host, signed in as the user,\n // pulls each staged upload from `users/{uid}/uploads/{id}` and deletes it\n // after ingest.\n storage: FirebaseStorage;\n}\n\nexport const createRemoteHostFirebase = (config: FirebaseOptions): RemoteHostFirebase => {\n const app = initializeApp(config);\n return { app, auth: getAuth(app), firestore: getFirestore(app), storage: getStorage(app) };\n};\n\nexport interface RemoteHostSessionHandles {\n auth: Auth;\n firestore: Firestore;\n storage: FirebaseStorage;\n // The signed-in uid restored from a seed blob, or null when opened fresh\n // (before `signInHost`) or when the blob held no valid session.\n uid: string | null;\n}\n\n// A restartable Firebase session for a host, backed by the export/seed-able\n// persistence (mulmoserver#50, \"case A'\"). Because `initializeAuth` reads\n// persistence once and can run only once per app, each `open` spins up a FRESH\n// app (unique name) with the persistence seeded first, then tears down the\n// previous app — so a reconnect can restore a browser-parked session, and a\n// fresh connect starts clean. `exportSession`/`onSessionChange` expose the blob\n// the browser stores and the signal to re-sync it.\n// Runs on the freshly opened handles BEFORE the previous session is torn down.\n// Reject to keep the open non-destructive: the fresh app is rolled back and the\n// previous session is preserved (so a failed sign-in or an expired blob doesn't\n// replace a healthy live session).\nexport type RemoteHostSessionValidate = (handles: RemoteHostSessionHandles) => Promise<void>;\n\nexport interface RemoteHostSession {\n open: (seedBlob?: string, validate?: RemoteHostSessionValidate) => Promise<RemoteHostSessionHandles>;\n close: () => Promise<void>;\n exportSession: () => string | null;\n onSessionChange: (cb: (blob: string | null) => void) => () => void;\n}\n\nconst noop = (): void => undefined;\n\n// Run operations one-at-a-time in submission order. `open`/`close` mutate shared\n// store/app state, so overlapping calls must not interleave (they would leak an\n// app or leave `app` pointing at a torn-down instance). Mirrors the lifecycle's\n// serialization; a failed op doesn't block the next.\nconst makeSerializer = () => {\n let transition: Promise<unknown> = Promise.resolve();\n return <T>(operation: () => Promise<T>): Promise<T> => {\n const next = transition.then(operation, operation);\n transition = next.then(noop, noop);\n return next;\n };\n};\n\n// Build a fresh app from the (already seeded) persistence and wait for the\n// restored auth state to settle. Persistence restore is async, so `uid`\n// reflects a seeded session only after `authStateReady()` (null when the blob\n// was empty/invalid, or on a fresh connect before `signInHost`).\nconst openFreshApp = async (\n config: FirebaseOptions,\n store: HostSessionPersistence,\n name: string,\n): Promise<{ app: FirebaseApp; handles: RemoteHostSessionHandles }> => {\n // `initializeApp` registers the app in the SDK's global registry, so if init\n // then throws we must delete it — otherwise repeated reconnect failures leak\n // a registered app each time.\n const app = initializeApp(config, name);\n try {\n const auth = initializeAuth(app, { persistence: store.persistence });\n await auth.authStateReady();\n return { app, handles: { auth, firestore: getFirestore(app), storage: getStorage(app), uid: auth.currentUser?.uid ?? null } };\n } catch (error) {\n await deleteApp(app).catch(() => undefined);\n throw error;\n }\n};\n\nexport const createRemoteHostSession = (config: FirebaseOptions): RemoteHostSession => {\n const store = createHostSessionPersistence();\n let app: FirebaseApp | null = null;\n let appSeq = 0;\n const serialize = makeSerializer();\n\n const closeInner = async (): Promise<void> => {\n const previous = app;\n app = null;\n store.clear();\n if (previous) await deleteApp(previous);\n };\n\n // Run the caller's check on the fresh handles; on rejection, delete the fresh\n // app so only the caller-visible error escapes and the previous session (never\n // touched here) stays live.\n const validateOrRollback = async (nextApp: FirebaseApp, handles: RemoteHostSessionHandles, validate?: RemoteHostSessionValidate): Promise<void> => {\n if (!validate) return;\n try {\n await validate(handles);\n } catch (error) {\n await deleteApp(nextApp).catch(() => undefined);\n throw error;\n }\n };\n\n const openInner = async (seedBlob?: string, validate?: RemoteHostSessionValidate): Promise<RemoteHostSessionHandles> => {\n // Non-destructive: keep the current session intact until the fresh app is\n // proven to come up AND pass `validate` (sign-in / uid). A bad seed blob, a\n // failed init, or a rejected validation must not tear down a healthy session\n // — the reconnect contract (mulmoserver#50). So we tear the previous app\n // down only AFTER success, and roll the store back on failure.\n const previousApp = app;\n const previousBlob = store.exportBlob();\n appSeq += 1;\n try {\n store.clear();\n if (seedBlob) store.seed(seedBlob);\n const { app: nextApp, handles } = await openFreshApp(config, store, `remote-host-${appSeq}`);\n await validateOrRollback(nextApp, handles, validate);\n app = nextApp;\n if (previousApp) await deleteApp(previousApp).catch(() => undefined);\n return handles;\n } catch (error) {\n store.clear();\n if (previousBlob) store.seed(previousBlob);\n throw error;\n }\n };\n\n return {\n open: (seedBlob?: string, validate?: RemoteHostSessionValidate) => serialize(() => openInner(seedBlob, validate)),\n close: () => serialize(closeInner),\n exportSession: store.exportBlob,\n onSessionChange: store.onChange,\n };\n};\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../../src/remote-host/server/hostRunner.ts","../../../src/remote-host/server/lifecycle.ts","../../../src/remote-host/server/auth.ts","../../../src/remote-host/server/sessionPersistence.ts","../../../src/remote-host/server/firebase.ts"],"sourcesContent":["// Host side of the command channel: claim queued commands, run handlers, write\n// results back, and announce presence via heartbeat.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/hostRunner.ts (itself\n// ported from ../mulmoserver). The only signature change vs. that copy: the\n// `firestore` instance is a parameter (each host supplies its own Firebase init),\n// and the heartbeat interval is an option (defaults to one minute).\nimport { DocumentReference, Firestore, deleteDoc, onSnapshot, query, runTransaction, serverTimestamp, setDoc, updateDoc, where } from \"firebase/firestore\";\n\nimport { errorMessage } from \"../../collection/core/errorMessage.js\";\nimport {\n Channel,\n Command,\n CommandHandler,\n CommandHandlers,\n JsonObject,\n buildHostPresence,\n byCreatedAt,\n commandsCollection,\n hostDoc,\n isExpired,\n} from \"../index.js\";\n\nconst DEFAULT_HEARTBEAT_MS = 60_000;\n\nexport interface HostEvent {\n phase: \"received\" | \"done\" | \"error\";\n method: string;\n message?: string;\n}\n\nexport interface HostRunnerOptions {\n onEvent?: (event: HostEvent) => void;\n // Called once when the listener dies fatally (after presence has been set\n // offline), so the lifecycle owner can reconcile its own state — e.g. clear\n // the runner handle so status() no longer reports connected. NOT called on a\n // normal stop().\n onClosed?: () => void;\n // Called when a command is dropped for being past its `expiresAt`, BEFORE the\n // doc is deleted, so the host can clean up out-of-band resources the command\n // referenced (e.g. staged attachment uploads in Storage). `uid` is THIS runner's\n // session uid (channel.uid) — passed in rather than read from a global so a\n // concurrent reconnect as a different account can't point cleanup at the wrong\n // user's Storage path. Best-effort: a throw is logged via onEvent and does NOT\n // block the doc deletion. Absent ⇒ the expired doc is simply deleted.\n onExpire?: (command: Command, uid: string) => void | Promise<void>;\n // Presence heartbeat interval; defaults to one minute.\n heartbeatMs?: number;\n}\n\ninterface Claim {\n method: string;\n params: JsonObject;\n}\n\nconst noop = () => undefined;\n\n// The remote may have deleted the doc on timeout, so ignore write-after-delete.\nconst writeError = (ref: DocumentReference, code: string, message: string) =>\n updateDoc(ref, { status: \"error\", error: { code, message }, updatedAt: serverTimestamp() }).catch(noop);\n\n// Atomically move a command queued -> processing so it is handled exactly once.\n// Returns the method/params to run, or null if another handler already took it.\nconst claimCommand = (firestore: Firestore, ref: DocumentReference): Promise<Claim | null> =>\n runTransaction(firestore, async (txn) => {\n const data = (await txn.get(ref)).data() as Command | undefined;\n if (!data || data.status !== \"queued\") {\n return null;\n }\n txn.update(ref, { status: \"processing\", updatedAt: serverTimestamp() });\n return { method: data.method, params: data.params ?? {} };\n });\n\nconst runHandler = async (ref: DocumentReference, claim: Claim, handler: CommandHandler): Promise<HostEvent> => {\n try {\n const result = await handler(claim.params);\n await updateDoc(ref, { status: \"done\", result: result ?? null, updatedAt: serverTimestamp() });\n return { phase: \"done\", method: claim.method };\n } catch (error) {\n const message = errorMessage(error);\n await writeError(ref, \"handler_error\", message);\n return { phase: \"error\", method: claim.method, message };\n }\n};\n\n// A command past its deadline is removed entirely rather than run: give the host\n// a chance to clean up out-of-band resources (staged attachments), then delete\n// the doc so it is neither reprocessed nor left as a stale error. Both steps are\n// best-effort/idempotent, so a snapshot replay surfacing the same expired doc\n// twice is harmless (no claim transaction needed — see plan edge #3).\nconst expireCommand = async (ref: DocumentReference, command: Command, options: HostRunnerOptions, uid: string) => {\n try {\n await options.onExpire?.(command, uid);\n } catch (error) {\n options.onEvent?.({ phase: \"error\", method: command.method, message: `onExpire failed: ${errorMessage(error)}` });\n }\n // Surface a delete failure (permissions / transient network) the same way the\n // onExpire failure above is surfaced — otherwise the expired doc lingers as\n // \"queued\" with no signal as to why cleanup didn't happen.\n await deleteDoc(ref).catch((error) => {\n options.onEvent?.({ phase: \"error\", method: command.method, message: `expire delete failed: ${errorMessage(error)}` });\n });\n options.onEvent?.({ phase: \"done\", method: command.method, message: \"expired\" });\n};\n\n// Per-runner constants bundled into one context so processCommand stays under the\n// max-params cap: firestore, the handler table, options, and the session uid are\n// all fixed for the runner's lifetime; only ref/command/now vary per command.\ninterface RunnerContext {\n firestore: Firestore;\n handlers: CommandHandlers;\n options: HostRunnerOptions;\n uid: string;\n}\n\nconst processCommand = async (ctx: RunnerContext, ref: DocumentReference, command: Command, now: number) => {\n const { handlers, options } = ctx;\n // Drop an expired command before claiming it — it must never reach a handler.\n if (isExpired(command, now)) {\n await expireCommand(ref, command, options, ctx.uid);\n return;\n }\n const claim = await claimCommand(ctx.firestore, ref);\n if (!claim) {\n return;\n }\n options.onEvent?.({ phase: \"received\", method: claim.method });\n const handler: CommandHandler | undefined = handlers[claim.method];\n if (!handler) {\n await writeError(ref, \"unknown_method\", `No handler for method: ${claim.method}`);\n options.onEvent?.({ phase: \"error\", method: claim.method, message: \"unknown method\" });\n return;\n }\n options.onEvent?.(await runHandler(ref, claim, handler));\n};\n\n// startHostRunner subscribes to queued commands for the given channel and runs\n// each one through the supplied handler table. It also announces presence (a\n// heartbeat on users/{uid}/hosts/{hostId}) so the remote can tell it is online.\n// Returns a stop function that goes offline and detaches the listener.\nexport const startHostRunner = (firestore: Firestore, channel: Channel, handlers: CommandHandlers, options: HostRunnerOptions = {}): (() => void) => {\n const presence = hostDoc(firestore, channel);\n // Advertise online/offline + the capability set (method names + protocol\n // version) on the same doc the remote already listens to for presence.\n // Returns void, not the promise: presence is advertised best-effort from\n // three call sites (announce, the snapshot-error path, the teardown), none of\n // which can await. Terminating the chain here with `.catch(noop)` keeps every\n // caller from floating a promise it has no way to handle.\n const writePresence = (online: boolean): void => {\n setDoc(presence, { ...buildHostPresence(channel, handlers, online), updatedAt: serverTimestamp() }).catch(noop);\n };\n const announce = () => {\n writePresence(true);\n };\n announce();\n const beat = setInterval(announce, options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS);\n\n const queuedCommands = query(commandsCollection(firestore, channel), where(\"status\", \"==\", \"queued\"));\n const ctx: RunnerContext = { firestore, handlers, options, uid: channel.uid };\n const unsubscribe = onSnapshot(\n queuedCommands,\n (snapshot) => {\n const now = Date.now();\n // Best-effort oldest-first DISPATCH only. Commands are processed\n // concurrently (not awaited in turn) and out-of-order completion is fine by\n // design — chat is asynchronous — so this sort just biases which command\n // starts first; it is not an ordering guarantee. We still sort in memory\n // rather than orderBy(\"createdAt\") on the query because a Firestore orderBy\n // silently EXCLUDES docs missing the field — which would drop every\n // pre-offline-queue command (no createdAt) from the queue entirely.\n const added = snapshot\n .docChanges()\n .filter((change) => change.type === \"added\")\n .map((change) => ({ ref: change.doc.ref, command: change.doc.data() as Command }))\n .sort((left, right) => byCreatedAt(left.command, right.command));\n added.forEach(({ ref, command }) => {\n processCommand(ctx, ref, command, now).catch(noop);\n });\n },\n (error) => {\n options.onEvent?.({ phase: \"error\", method: \"listen\", message: error.message });\n // A Firestore onSnapshot error terminates the listener and it does not\n // recover on its own. Stop advertising presence (clear the heartbeat +\n // write online:false) so remotes see the host as offline instead of a\n // live host that silently consumes no commands.\n clearInterval(beat);\n writePresence(false);\n options.onClosed?.();\n },\n );\n\n return () => {\n clearInterval(beat);\n writePresence(false);\n unsubscribe();\n };\n};\n","// Remote-host lifecycle: wire a Firebase session to the Firestore host runner so\n// connecting starts the command loop + presence heartbeat, and disconnecting\n// stops both.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/index.ts. The factory\n// takes injected collaborators — real hosts bind them to Firebase; tests pass\n// fakes to exercise the invariants (non-destructive connect, serialized\n// transitions, status/liveness reconciliation on fatal listener death) without a\n// network. `hostId` and the logger are injected too, so this file imports no\n// Firebase and no host logger and stays trivially unit-testable.\n//\n// Single-account, single-host per instance, in-memory session: a host restart\n// drops the session and needs a re-connect.\nimport type { Channel, Command, CommandHandlers } from \"../index.js\";\nimport type { HostRunnerOptions } from \"./hostRunner.js\";\n\nexport interface RemoteHostStatus {\n connected: boolean;\n uid: string | null;\n}\n\n// Minimal logger the factory calls; each host adapts its own logger to this\n// shape (or omits it to run silently, as the tests do).\nexport interface RemoteHostLogger {\n info: (msg: string) => void;\n warn: (msg: string) => void;\n debug: (msg: string) => void;\n}\n\n// Injectable collaborators — a real host binds these to Firebase + its own\n// hostId; tests pass fakes to exercise the lifecycle without a network.\n// `startRunner` is the host's `startHostRunner` pre-bound with its Firestore\n// instance, so this module needs no Firebase of its own.\nexport interface RemoteHostDeps {\n hostId: string;\n signIn: (idToken: string) => Promise<string>;\n // Restore a session from a browser-parked blob (case A', mulmoserver#50) and\n // resolve to its uid — no popup. MUST reject when the blob yields no valid\n // session so `reconnect` stays non-destructive. Absent ⇒ `reconnect` throws.\n restore?: (sessionBlob: string) => Promise<string>;\n signOut: () => Promise<void>;\n currentUid: () => string | null;\n startRunner: (channel: Channel, handlers: CommandHandlers, options: HostRunnerOptions) => () => void;\n handlers: CommandHandlers;\n // Optional host-specific cleanup for a command the runner drops as expired\n // (e.g. delete its staged attachment uploads). Threaded verbatim into the\n // runner's `onExpire`, which supplies the session `uid` so cleanup targets the\n // right user's Storage path even across a reconnect; absent ⇒ an expired doc is\n // just deleted.\n onExpire?: (command: Command, uid: string) => void | Promise<void>;\n log?: RemoteHostLogger;\n}\n\nexport interface RemoteHostLifecycle {\n connect: (idToken: string) => Promise<RemoteHostStatus>;\n // Restore a browser-parked session and start the runner — popup-free re-attach\n // after a host restart. Non-destructive like `connect`. Rejects if `deps.restore`\n // isn't wired, or if restore fails (blob expired/invalid → caller falls back to connect).\n reconnect: (sessionBlob: string) => Promise<RemoteHostStatus>;\n disconnect: () => Promise<RemoteHostStatus>;\n status: () => RemoteHostStatus;\n}\n\nconst noop = () => undefined;\nconst silentLogger: RemoteHostLogger = { info: noop, warn: noop, debug: noop };\n\nexport const createRemoteHost = (deps: RemoteHostDeps): RemoteHostLifecycle => {\n const log = deps.log ?? silentLogger;\n\n // The running host runner's stop() handle, or null when disconnected. Keeps\n // the single-host invariant — one runner at a time.\n let stopRunner: (() => void) | null = null;\n\n // Serialize connect/disconnect so overlapping requests can't both mutate\n // stopRunner (which would leak a second runner) or race auth against teardown.\n // Each transition runs only after the previous one settles; a failed\n // transition does not block the next (both handlers run the next op).\n let transition: Promise<unknown> = Promise.resolve();\n\n const serialize = <T>(operation: () => Promise<T>): Promise<T> => {\n const next = transition.then(operation, operation);\n transition = next.then(noop, noop);\n return next;\n };\n\n const stopIfRunning = () => {\n if (stopRunner) {\n stopRunner();\n stopRunner = null;\n }\n };\n\n const status = (): RemoteHostStatus => ({ connected: stopRunner !== null, uid: deps.currentUid() });\n\n // Swap the running runner for one bound to `uid`. Callers authenticate FIRST\n // (before this teardown) so a failed connect/reconnect keeps a healthy session.\n const attach = (uid: string, verb: string): RemoteHostStatus => {\n stopIfRunning();\n const runner = deps.startRunner({ uid, hostId: deps.hostId }, deps.handlers, {\n onEvent: (event) => log.debug(`host event: ${event.phase} ${event.method}`),\n onExpire: deps.onExpire,\n // Fatal listener death: drop the handle so status() stops reporting\n // connected — but only if it still points at THIS runner (a later\n // reconnect may have replaced it).\n onClosed: () => {\n if (stopRunner === runner) {\n stopRunner = null;\n log.warn(\"host runner listener died; marked disconnected\");\n }\n },\n });\n stopRunner = runner;\n log.info(`${verb} as ${uid}, host runner started (hostId=${deps.hostId})`);\n return status();\n };\n\n const connect = (idToken: string): Promise<RemoteHostStatus> => serialize(async () => attach(await deps.signIn(idToken), \"connected\"));\n\n const reconnect = (sessionBlob: string): Promise<RemoteHostStatus> =>\n serialize(async () => {\n const { restore } = deps;\n if (!restore) throw new Error(\"remote-host reconnect requires a `restore` dependency\");\n return attach(await restore(sessionBlob), \"reconnected\");\n });\n\n const disconnect = (): Promise<RemoteHostStatus> =>\n serialize(async () => {\n stopIfRunning();\n await deps.signOut();\n log.info(\"disconnected, host runner stopped\");\n return status();\n });\n\n return { connect, reconnect, disconnect, status };\n};\n","// Firebase credential exchange for the remote-host runner.\n//\n// The host authenticates to Firestore *as the user* (Option B) using the\n// Firebase JS SDK's signInWithCredential with a browser-minted Google OAuth ID\n// token — no Admin SDK, no project service account. Security rules keep the host\n// scoped to that user's own users/{uid}/… subtree.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/auth.ts. The `auth`\n// instance is a parameter so each host binds its own Firebase init; the factory\n// returns the low-level credential primitives (the connect/disconnect lifecycle\n// that starts/stops the host runner lives in createRemoteHost).\nimport { Auth, GoogleAuthProvider, signInWithCredential, signOut } from \"firebase/auth\";\n\nexport interface RemoteHostAuth {\n // Establish the Firebase session from a browser-minted Google OAuth ID token.\n // The token is used once here; the JS SDK then holds its own refresh token for\n // the process lifetime. Resolves to the authenticated uid.\n signInHost: (idToken: string) => Promise<string>;\n // Tear down the Firebase session (in-memory persistence → re-login on restart).\n signOutHost: () => Promise<void>;\n // The currently signed-in uid, or null when disconnected.\n currentUid: () => string | null;\n}\n\nexport const createRemoteHostAuth = (auth: Auth): RemoteHostAuth => ({\n signInHost: async (idToken: string): Promise<string> => {\n const credential = GoogleAuthProvider.credential(idToken);\n const userCredential = await signInWithCredential(auth, credential);\n return userCredential.user.uid;\n },\n signOutHost: (): Promise<void> => signOut(auth),\n currentUid: (): string | null => auth.currentUser?.uid ?? null,\n});\n","// A custom Firebase Auth persistence that keeps the signed-in session in memory\n// but can be seeded from — and exported back to — an opaque blob, so a host's\n// Firebase session survives a process restart by being parked in the browser's\n// localStorage (mulmoserver#50, \"case A'\"). The blob is whatever the SDK wrote\n// into persistence, round-tripped through JSON; we NEVER interpret its fields,\n// so we don't couple to the SDK's serialized-user format across versions.\n//\n// **Persistence is a CLASS, not an instance.** `initializeAuth(app, { persistence })`\n// hands the value to the SDK's `_getInstance(cls)`, which asserts `cls instanceof\n// Function` (\"Expected a class definition\") and then `new cls()`. Passing a plain\n// object throws — the SDK's own `inMemoryPersistence` is likewise a class. The\n// class is defined per factory call so its instances share this call's store;\n// `type: \"LOCAL\"` makes the SDK treat it as durable and persist the user.\n//\n// Seed BEFORE `initializeAuth` — the SDK reads persistence once at init. `onChange`\n// fires when the SDK writes/removes a key (a token update that rotates the stored\n// blob, or a sign-out) — the signal to re-sync the browser copy.\nimport type { Persistence } from \"firebase/auth\";\n\n// The SDK stores values as JSON objects (a serialized user) or strings. We treat\n// them opaquely — a value is either.\ntype PersistenceValue = Record<string, unknown> | string;\n\ntype StorageListener = (value: PersistenceValue | null) => void;\n\n// The instance the SDK builds via `new` and then drives with these `_`-methods.\nexport interface HostAuthPersistenceInstance extends Persistence {\n type: \"LOCAL\";\n _isAvailable: () => Promise<boolean>;\n _set: (key: string, value: PersistenceValue) => Promise<void>;\n _get: (key: string) => Promise<PersistenceValue | null>;\n _remove: (key: string) => Promise<void>;\n _addListener: (key: string, listener: StorageListener) => void;\n _removeListener: (key: string, listener: StorageListener) => void;\n}\n\n// The class value passed to `initializeAuth`. The static `type` makes it\n// assignable to the SDK's `Persistence` (a class-valued-typed-as-instance, the\n// same trick the SDK uses for `inMemoryPersistence`); `new ()` lets the SDK — and\n// the tests — instantiate it.\nexport interface HostAuthPersistenceClass extends Persistence {\n type: \"LOCAL\";\n new (): HostAuthPersistenceInstance;\n}\n\nexport interface HostSessionPersistence {\n /** Pass to `initializeAuth(app, { persistence })`. It's a class the SDK `new`s. */\n persistence: HostAuthPersistenceClass;\n /** Load a previously exported blob. Call BEFORE `initializeAuth`. */\n seed: (blob: string) => void;\n /** Serialize the current contents, or `null` when empty (no session). */\n exportBlob: () => string | null;\n /** Notified with the fresh blob (or `null`) whenever the SDK writes/removes. */\n onChange: (listener: (blob: string | null) => void) => () => void;\n /** Drop all contents (used when tearing down the Firebase app). */\n clear: () => void;\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> => typeof value === \"object\" && value !== null && !Array.isArray(value);\n\nconst isPersistenceValue = (value: unknown): value is PersistenceValue => typeof value === \"string\" || isRecord(value);\n\n// True when `blob` is a JSON object `seed` can load. A blob that fails this can\n// never restore a session (corrupt localStorage, wrong shape), so callers treat\n// it as an expired/invalid session rather than a transient error — the client\n// then drops it instead of retrying the same doomed blob forever.\nexport const isSeedableBlob = (blob: string): boolean => {\n try {\n return isRecord(JSON.parse(blob));\n } catch {\n return false;\n }\n};\n\n// A class (constructor) so the SDK's `_getInstance` accepts it (it asserts\n// `cls instanceof Function`, then `new cls()`); instances hold the shared\n// `store`/`notify`, so a fresh `new` still sees the seeded/live data. `static\n// type` is for the `Persistence` type bridge; the instance `type` is what the\n// SDK reads after `new`.\nconst makeHostPersistenceClass = (store: Map<string, PersistenceValue>, notify: () => void): HostAuthPersistenceClass => {\n class HostAuthPersistence {\n static readonly type = \"LOCAL\" as const;\n readonly type = \"LOCAL\" as const;\n private readonly store = store;\n private readonly notify = notify;\n private readonly external = new Set<StorageListener>();\n _isAvailable(): Promise<boolean> {\n return Promise.resolve(this.store instanceof Map);\n }\n async _set(key: string, value: PersistenceValue): Promise<void> {\n this.store.set(key, value);\n this.notify();\n }\n _get(key: string): Promise<PersistenceValue | null> {\n return Promise.resolve(this.store.get(key) ?? null);\n }\n async _remove(key: string): Promise<void> {\n this.store.delete(key);\n this.notify();\n }\n // Node has no cross-tab storage events to deliver, so registered listeners\n // never fire; we still track them so add/remove stay symmetric.\n _addListener(_key: string, listener: StorageListener): void {\n this.external.add(listener);\n }\n _removeListener(_key: string, listener: StorageListener): void {\n this.external.delete(listener);\n }\n }\n return HostAuthPersistence;\n};\n\nexport const createHostSessionPersistence = (): HostSessionPersistence => {\n const store = new Map<string, PersistenceValue>();\n const listeners = new Set<(blob: string | null) => void>();\n\n const exportBlob = (): string | null => (store.size === 0 ? null : JSON.stringify(Object.fromEntries(store)));\n\n const notify = (): void => {\n const blob = exportBlob();\n listeners.forEach((listener) => listener(blob));\n };\n\n const persistence = makeHostPersistenceClass(store, notify);\n\n const seed = (blob: string): void => {\n const parsed: unknown = JSON.parse(blob);\n if (!isRecord(parsed)) throw new Error(\"host session blob must be a JSON object\");\n store.clear();\n for (const [key, value] of Object.entries(parsed)) {\n if (isPersistenceValue(value)) store.set(key, value);\n }\n };\n\n const onChange = (listener: (blob: string | null) => void): (() => void) => {\n listeners.add(listener);\n return () => listeners.delete(listener);\n };\n\n const clear = (): void => {\n store.clear();\n };\n\n return { persistence, seed, exportBlob, onChange, clear };\n};\n","// Firebase init for a remote-host runner.\n//\n// A host acts as a *host*: it signs in to Firebase as the user (via\n// signInWithCredential, see auth.ts) and listens to that user's command queue in\n// Firestore. The modular firebase/firestore + firebase/auth SDKs run in Node, so\n// this mirrors a browser init but also exposes Firestore (default database,\n// which must be in Native mode) and Storage.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/firebase.ts. The\n// public web config is a parameter so each host supplies its own (both hosts\n// reuse the shared mulmoserver project).\nimport { deleteApp, FirebaseApp, FirebaseOptions, initializeApp } from \"firebase/app\";\nimport { Auth, getAuth, initializeAuth } from \"firebase/auth\";\nimport { Firestore, getFirestore } from \"firebase/firestore\";\nimport { FirebaseStorage, getStorage } from \"firebase/storage\";\n\nimport { createHostSessionPersistence, type HostSessionPersistence } from \"./sessionPersistence.js\";\n\nexport interface RemoteHostFirebase {\n app: FirebaseApp;\n auth: Auth;\n firestore: Firestore;\n // Storage carries the full-res attachment bytes the command channel can't (a\n // Firestore command doc caps at ~1 MiB). The host, signed in as the user,\n // pulls each staged upload from `users/{uid}/uploads/{id}` and deletes it\n // after ingest.\n storage: FirebaseStorage;\n}\n\nexport const createRemoteHostFirebase = (config: FirebaseOptions): RemoteHostFirebase => {\n const app = initializeApp(config);\n return { app, auth: getAuth(app), firestore: getFirestore(app), storage: getStorage(app) };\n};\n\nexport interface RemoteHostSessionHandles {\n auth: Auth;\n firestore: Firestore;\n storage: FirebaseStorage;\n // The signed-in uid restored from a seed blob, or null when opened fresh\n // (before `signInHost`) or when the blob held no valid session.\n uid: string | null;\n}\n\n// A restartable Firebase session for a host, backed by the export/seed-able\n// persistence (mulmoserver#50, \"case A'\"). Because `initializeAuth` reads\n// persistence once and can run only once per app, each `open` spins up a FRESH\n// app (unique name) with the persistence seeded first, then tears down the\n// previous app — so a reconnect can restore a browser-parked session, and a\n// fresh connect starts clean. `exportSession`/`onSessionChange` expose the blob\n// the browser stores and the signal to re-sync it.\n// Runs on the freshly opened handles BEFORE the previous session is torn down.\n// Reject to keep the open non-destructive: the fresh app is rolled back and the\n// previous session is preserved (so a failed sign-in or an expired blob doesn't\n// replace a healthy live session).\nexport type RemoteHostSessionValidate = (handles: RemoteHostSessionHandles) => Promise<void>;\n\nexport interface RemoteHostSession {\n open: (seedBlob?: string, validate?: RemoteHostSessionValidate) => Promise<RemoteHostSessionHandles>;\n close: () => Promise<void>;\n exportSession: () => string | null;\n onSessionChange: (cb: (blob: string | null) => void) => () => void;\n}\n\nconst noop = (): void => undefined;\n\n// Run operations one-at-a-time in submission order. `open`/`close` mutate shared\n// store/app state, so overlapping calls must not interleave (they would leak an\n// app or leave `app` pointing at a torn-down instance). Mirrors the lifecycle's\n// serialization; a failed op doesn't block the next.\nconst makeSerializer = () => {\n let transition: Promise<unknown> = Promise.resolve();\n return <T>(operation: () => Promise<T>): Promise<T> => {\n const next = transition.then(operation, operation);\n transition = next.then(noop, noop);\n return next;\n };\n};\n\n// Build a fresh app from the (already seeded) persistence and wait for the\n// restored auth state to settle. Persistence restore is async, so `uid`\n// reflects a seeded session only after `authStateReady()` (null when the blob\n// was empty/invalid, or on a fresh connect before `signInHost`).\nconst openFreshApp = async (\n config: FirebaseOptions,\n store: HostSessionPersistence,\n name: string,\n): Promise<{ app: FirebaseApp; handles: RemoteHostSessionHandles }> => {\n // `initializeApp` registers the app in the SDK's global registry, so if init\n // then throws we must delete it — otherwise repeated reconnect failures leak\n // a registered app each time.\n const app = initializeApp(config, name);\n try {\n const auth = initializeAuth(app, { persistence: store.persistence });\n await auth.authStateReady();\n return { app, handles: { auth, firestore: getFirestore(app), storage: getStorage(app), uid: auth.currentUser?.uid ?? null } };\n } catch (error) {\n await deleteApp(app).catch(() => undefined);\n throw error;\n }\n};\n\nexport const createRemoteHostSession = (config: FirebaseOptions): RemoteHostSession => {\n const store = createHostSessionPersistence();\n let app: FirebaseApp | null = null;\n let appSeq = 0;\n const serialize = makeSerializer();\n\n const closeInner = async (): Promise<void> => {\n const previous = app;\n app = null;\n store.clear();\n if (previous) await deleteApp(previous);\n };\n\n // Run the caller's check on the fresh handles; on rejection, delete the fresh\n // app so only the caller-visible error escapes and the previous session (never\n // touched here) stays live.\n const validateOrRollback = async (nextApp: FirebaseApp, handles: RemoteHostSessionHandles, validate?: RemoteHostSessionValidate): Promise<void> => {\n if (!validate) return;\n try {\n await validate(handles);\n } catch (error) {\n await deleteApp(nextApp).catch(() => undefined);\n throw error;\n }\n };\n\n const openInner = async (seedBlob?: string, validate?: RemoteHostSessionValidate): Promise<RemoteHostSessionHandles> => {\n // Non-destructive: keep the current session intact until the fresh app is\n // proven to come up AND pass `validate` (sign-in / uid). A bad seed blob, a\n // failed init, or a rejected validation must not tear down a healthy session\n // — the reconnect contract (mulmoserver#50). So we tear the previous app\n // down only AFTER success, and roll the store back on failure.\n const previousApp = app;\n const previousBlob = store.exportBlob();\n appSeq += 1;\n try {\n store.clear();\n if (seedBlob) store.seed(seedBlob);\n const { app: nextApp, handles } = await openFreshApp(config, store, `remote-host-${appSeq}`);\n await validateOrRollback(nextApp, handles, validate);\n app = nextApp;\n if (previousApp) await deleteApp(previousApp).catch(() => undefined);\n return handles;\n } catch (error) {\n store.clear();\n if (previousBlob) store.seed(previousBlob);\n throw error;\n }\n };\n\n return {\n open: (seedBlob?: string, validate?: RemoteHostSessionValidate) => serialize(() => openInner(seedBlob, validate)),\n close: () => serialize(closeInner),\n exportSession: store.exportBlob,\n onSessionChange: store.onChange,\n };\n};\n"],"mappings":"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"}
@@ -1,4 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_project = require("../project-BWI5w_BT.cjs");
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.
@@ -76,11 +77,12 @@ var normalizeFields = (value) => {
76
77
  /** Keep only `fields` (+ always the primary key) on each record. Parents apply
77
78
  * this uniformly — the desktop preview via `pageFromItems`, the phone parent
78
79
  * over the page it fetched through the channel — so a view sees the same
79
- * projection everywhere. No-op without `fields`. */
80
+ * projection everywhere. No-op without `fields`; an EMPTY `fields` array is
81
+ * also a no-op here (frozen bridge behavior — the shared helper would
82
+ * project down to the primary key alone). */
80
83
  function projectItems(items, fields, primaryKey) {
81
84
  if (!fields || fields.length === 0) return items;
82
- const keep = /* @__PURE__ */ new Set([primaryKey, ...fields]);
83
- return items.map((item) => Object.fromEntries(Object.entries(item).filter(([key]) => keep.has(key))));
85
+ return require_project.projectRecordFields(items, fields, primaryKey);
84
86
  }
85
87
  /** Answer a page request from an already-loaded record array (the desktop
86
88
  * preview's data source): slice + project. Observable behavior matches the
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","names":[],"sources":["../../src/remote-view/index.ts"],"sourcesContent":["// The remote custom-view contract (phase 3 — plans/feat-remote-custom-view.md).\n//\n// Browser-safe single source of truth shared by the host server (which wraps\n// the view HTML into a sandboxed srcdoc), the desktop phone-frame preview, and\n// the mulmoserver mobile client (post-publish). A remote view runs on a phone\n// that can reach the internet but NOT the host's localhost, so — unlike the\n// desktop custom view (token + fetch to the view-data route) — its records\n// arrive over an async postMessage bridge owned by the parent page, and its\n// CSP locks `connect-src` to 'none' entirely.\n\n/** Bump when the bootstrap/message contract changes shape; the bootstrap\n * exposes it as `__MC_VIEW.protocol` so a parent can refuse a stale view.\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/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/** 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`. */\nexport function projectItems(items: RemoteViewItem[], fields: string[] | undefined, primaryKey: string): RemoteViewItem[] {\n if (!fields || fields.length === 0) return items;\n const keep = new Set([primaryKey, ...fields]);\n return items.map((item) => Object.fromEntries(Object.entries(item).filter(([key]) => keep.has(key))));\n}\n\n/** Answer a page request from an already-loaded record array (the desktop\n * preview's data source): slice + project. Observable behavior matches the\n * phone paging over the command channel. */\nexport function pageFromItems(items: RemoteViewItem[], request: RemoteViewPageRequest, primaryKey: string): RemoteViewPage {\n const pageItems = items.slice(request.offset, request.offset + request.limit);\n return { items: projectItems(pageItems, request.fields, primaryKey), total: items.length, offset: request.offset, limit: request.limit };\n}\n\n/**\n * CSP for a remote (mobile) custom view. Stricter than the desktop custom-view\n * policy: the view's data arrives over postMessage, so `connect-src` is\n * `'none'` — no fetch / XHR / WebSocket / sendBeacon to ANY origin, which\n * closes the bidirectional-exfiltration channel completely (there is no token\n * to steal either). Script/style/font keep the curated CDN allowlist (the\n * phone can reach the internet; only the host is unreachable), and\n * `img-src`/`media-src` allow any `https:` host so record image/media URLs\n * render — the same knowingly-accepted one-way GET-exfil tradeoff as the\n * desktop policy (see buildCustomViewCsp in src/utils/html/previewCsp.ts).\n */\nexport function buildRemoteViewCsp(cdns: readonly string[] = SANDBOXED_VIEW_CDN_ALLOWLIST): string {\n const cdnList = cdns.join(\" \");\n return [\n \"default-src 'none'\",\n `script-src 'unsafe-inline' ${cdnList}`,\n `style-src 'unsafe-inline' ${cdnList}`,\n `font-src ${cdnList}`,\n `img-src ${cdnList} data: blob: https:`,\n \"media-src https: data: blob:\",\n \"connect-src 'none'\",\n ].join(\"; \");\n}\n\n/** The in-iframe bootstrap installed before any of the view's own scripts.\n * Owns the fiddly part of the contract — request/response correlation — so an\n * LLM-authored view only ever awaits `__MC_VIEW.getItems(...)` /\n * `.updateItem(...)` / `.deleteItem(...)`:\n *\n * - `getItems({ offset, limit, fields })`: posts an `mc-remote-get-items`\n * with a fresh `requestId`, resolves on the matching `mc-remote-items`\n * reply (validated to come from `window.parent`), rejects on `ok: false`\n * or after 30 s. targetOrigin `'*'` is safe: the request carries no secret\n * and the parent is by construction the party supplying the data.\n * - `updateItem(id, patch)` / `deleteItem(id)` (phase 4): post an\n * `mc-remote-mutate` and resolve on the matching `mc-remote-mutate-result`,\n * sharing the same `call()` correlation as `getItems`. Installed ONLY when\n * the host set `writable` (the view declared `editableFields`/`allowDelete`);\n * otherwise both reject `\"this view is read-only\"` so a mis-declared view\n * fails loudly instead of silently no-op'ing. The HOST still re-derives and\n * enforces the write policy — `writable` only gates the client surface.\n * - `startChat(prompt, role)`: same message type + semantics as the desktop\n * bridge — the parent opens a new chat with `prompt` prefilled as an\n * editable draft, never auto-sent.\n * - `t(key, named)`: the same vue-i18n-compatible dict helper as the desktop\n * bootstrap (named interpolation only), over the host-picked `dict`.\n *\n * Self-contained one-line string (no `<`, no `</script>`, `${}` only for the\n * interpolated constants). */\nfunction remoteViewBootstrap(): string {\n return `(function(){var v=window.__MC_VIEW,seq=0,pend={};window.addEventListener('message',function(e){if(e.source!==window.parent)return;var d=e.data;if(!d)return;if(d.type!=='${REMOTE_VIEW_MESSAGES.items}'&&d.type!=='${REMOTE_VIEW_MESSAGES.mutateResult}')return;var p=pend[d.requestId];if(!p)return;delete pend[d.requestId];clearTimeout(p.timer);if(d.ok)p.resolve(d.type==='${REMOTE_VIEW_MESSAGES.items}'?d.page:d.result);else p.reject(new Error(typeof d.error==='string'?d.error:'request failed'));});function call(type,payload){return new Promise(function(resolve,reject){var id='q'+(++seq);var timer=setTimeout(function(){delete pend[id];reject(new Error(type+' timed out'));},${GET_ITEMS_TIMEOUT_MS});pend[id]={resolve:resolve,reject:reject,timer:timer};var m={type:type,slug:v.slug,requestId:id};for(var k in payload){m[k]=payload[k];}window.parent.postMessage(m,'*');});}v.getItems=function(opts){opts=opts&&typeof opts==='object'?opts:{};return call('${REMOTE_VIEW_MESSAGES.getItems}',{offset:opts.offset,limit:opts.limit,fields:opts.fields});};if(v.writable){v.updateItem=function(id,patch){return call('${REMOTE_VIEW_MESSAGES.mutate}',{op:'update',id:String(id),patch:patch&&typeof patch==='object'?patch:{}});};v.deleteItem=function(id){return call('${REMOTE_VIEW_MESSAGES.mutate}',{op:'delete',id:String(id)});};}else{v.updateItem=v.deleteItem=function(){return Promise.reject(new Error('this view is read-only'));};}v.startChat=function(prompt,role){window.parent.postMessage({type:'${REMOTE_VIEW_MESSAGES.startChat}',slug:v.slug,prompt:String(prompt),role:typeof role==='string'?role:undefined},'*');};v.dict=v.dict||{};v.t=function(key,named){var s=v.dict[key];if(typeof s!=='string')return typeof key==='string'?key:String(key);if(!named||typeof named!=='object')return s;return s.replace(/\\\\{(\\\\w+)\\\\}/g,function(m,n){var x=named[n];return x==null?m:String(x);});};})();`;\n}\n\n/** What the host injects into `window.__MC_VIEW` — note what is ABSENT\n * compared to the desktop boot: no token, no dataUrl, no origin. */\nexport interface RemoteViewBoot {\n slug: string;\n /** Locale the dict was picked for; empty string when no translations. */\n locale?: string;\n /** Host-picked, locale-filtered flat string map (same contract as the\n * desktop custom-view dict). */\n dict?: Record<string, string>;\n /** True when the view declared a mutable surface (`editableFields` and/or\n * `allowDelete`). Gates the client-side `updateItem`/`deleteItem` install\n * only — the host re-enforces the actual policy on every mutate. */\n writable?: boolean;\n}\n\n/** Wrap a view's HTML into the sandboxed srcdoc: CSP meta + `__MC_VIEW` boot +\n * bridge bootstrap injected at the start of `<head>` (before any view\n * script). Runs HOST-side (`getRemoteView`) so the phone and the desktop\n * preview receive the identical finished artifact. */\nexport function buildRemoteViewSrcdoc(html: string, boot: RemoteViewBoot): string {\n const cspMeta = `<meta http-equiv=\"Content-Security-Policy\" content=\"${buildRemoteViewCsp()}\">`;\n // `<`-escape the JSON so a hostile slug/dict string can't break out of the\n // <script> element (same escape as the desktop srcdoc builder).\n const json = JSON.stringify({\n slug: boot.slug,\n locale: boot.locale ?? \"\",\n dict: boot.dict ?? {},\n target: \"mobile\",\n protocol: REMOTE_VIEW_PROTOCOL,\n writable: boot.writable ?? false,\n }).replace(/</g, \"\\\\u003c\");\n const injection = `${cspMeta}<script>window.__MC_VIEW=${json};${remoteViewBootstrap()}</script>`;\n if (/<head\\b[^>]*>/i.test(html)) {\n return html.replace(/(<head\\b[^>]*>)/i, `$1${injection}`);\n }\n return `<!DOCTYPE html><html><head>${injection}</head><body>${html}</body></html>`;\n}\n\n/** A normalized mutate request handed to a parent's `onMutate`\n * (`handleRemoteViewMessage` validates op/id/patch first). `update` carries a\n * partial record; the HOST decides which keys are actually writable. */\nexport type RemoteViewMutateRequest = { op: \"update\"; id: string; patch: Record<string, unknown> } | { op: \"delete\"; id: string };\n\n/** The resolved value of a mutate: the merged record for an update, the removed\n * id for a delete. Sent back to the view as the `mc-remote-mutate-result`\n * `result`. */\nexport interface RemoteViewMutateResult {\n item?: RemoteViewItem;\n id?: string;\n}\n\n/** What a parent page provides to answer the sandboxed view. Deliberately\n * minimal — exactly the phone runtime's capabilities, nothing more, so the\n * desktop preview can never exceed what works on the phone. */\nexport interface RemoteViewBridgeHandlers {\n slug: string;\n /** Answer one normalized page request (already clamped + fields-cleaned). */\n getPage: (request: RemoteViewPageRequest) => Promise<RemoteViewPage> | RemoteViewPage;\n /** Apply one normalized mutate (update/delete). Omit on a read-only parent —\n * the handler then replies `ok: false, \"this view is read-only\"`. The parent\n * forwards to the host (which enforces the write policy authoritatively). */\n onMutate?: (request: RemoteViewMutateRequest) => Promise<RemoteViewMutateResult> | RemoteViewMutateResult;\n /** Relay a `startChat` draft; omit on a parent without a chat surface. */\n onStartChat?: (prompt: string, role?: string) => void;\n}\n\n/** Coerce an untyped `mc-remote-mutate` payload to a normalized request, or\n * null when it is malformed (unknown op, missing id, non-object update patch —\n * the parent then replies with an `\"invalid mutate request\"` error). */\nexport function normalizeMutate(data: { op?: unknown; id?: unknown; patch?: unknown }): RemoteViewMutateRequest | null {\n const itemId = typeof data.id === \"string\" ? data.id : typeof data.id === \"number\" && Number.isFinite(data.id) ? String(data.id) : \"\";\n if (!itemId) return null;\n if (data.op === \"delete\") return { op: \"delete\", id: itemId };\n if (data.op === \"update\") {\n if (typeof data.patch !== \"object\" || data.patch === null || Array.isArray(data.patch)) return null;\n return { op: \"update\", id: itemId, patch: data.patch as Record<string, unknown> };\n }\n return null;\n}\n\nasync function answerGetItems(requestId: string, request: RemoteViewPageRequest, handlers: RemoteViewBridgeHandlers, reply: RemoteViewReply): Promise<void> {\n try {\n const page = await handlers.getPage(request);\n reply({ type: REMOTE_VIEW_MESSAGES.items, requestId, ok: true, page });\n } catch (err) {\n reply({ type: REMOTE_VIEW_MESSAGES.items, requestId, ok: false, error: err instanceof Error ? err.message : String(err) });\n }\n}\n\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.cjs","names":[],"sources":["../../src/remote-view/index.ts"],"sourcesContent":["// The remote custom-view contract (phase 3 — plans/feat-remote-custom-view.md).\n//\n// Browser-safe single source of truth shared by the host server (which wraps\n// the view HTML into a sandboxed srcdoc), the desktop phone-frame preview, and\n// the mulmoserver mobile client (post-publish). A remote view runs on a phone\n// that can reach the internet but NOT the host's localhost, so — unlike the\n// desktop custom view (token + fetch to the view-data route) — its records\n// arrive over an async postMessage bridge owned by the parent page, and its\n// CSP locks `connect-src` to 'none' entirely.\n//\n// 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/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/** 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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"}
@@ -66,7 +66,9 @@ export interface RemoteViewPageRequest {
66
66
  /** Keep only `fields` (+ always the primary key) on each record. Parents apply
67
67
  * this uniformly — the desktop preview via `pageFromItems`, the phone parent
68
68
  * over the page it fetched through the channel — so a view sees the same
69
- * projection everywhere. No-op without `fields`. */
69
+ * projection everywhere. No-op without `fields`; an EMPTY `fields` array is
70
+ * also a no-op here (frozen bridge behavior — the shared helper would
71
+ * project down to the primary key alone). */
70
72
  export declare function projectItems(items: RemoteViewItem[], fields: string[] | undefined, primaryKey: string): RemoteViewItem[];
71
73
  /** Answer a page request from an already-loaded record array (the desktop
72
74
  * preview's data source): slice + project. Observable behavior matches the
@@ -1,3 +1,4 @@
1
+ import { t as projectRecordFields } from "../project-bU98ycsy.js";
1
2
  //#region src/remote-view/index.ts
2
3
  /** Bump when the bootstrap/message contract changes shape; the bootstrap
3
4
  * exposes it as `__MC_VIEW.protocol` so a parent can refuse a stale view.
@@ -75,11 +76,12 @@ var normalizeFields = (value) => {
75
76
  /** Keep only `fields` (+ always the primary key) on each record. Parents apply
76
77
  * this uniformly — the desktop preview via `pageFromItems`, the phone parent
77
78
  * over the page it fetched through the channel — so a view sees the same
78
- * projection everywhere. No-op without `fields`. */
79
+ * projection everywhere. No-op without `fields`; an EMPTY `fields` array is
80
+ * also a no-op here (frozen bridge behavior — the shared helper would
81
+ * project down to the primary key alone). */
79
82
  function projectItems(items, fields, primaryKey) {
80
83
  if (!fields || fields.length === 0) return items;
81
- const keep = /* @__PURE__ */ new Set([primaryKey, ...fields]);
82
- return items.map((item) => Object.fromEntries(Object.entries(item).filter(([key]) => keep.has(key))));
84
+ return projectRecordFields(items, fields, primaryKey);
83
85
  }
84
86
  /** Answer a page request from an already-loaded record array (the desktop
85
87
  * preview's data source): slice + project. Observable behavior matches the