@rubytech/create-sitedesk-code 0.1.518 → 0.1.519

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 (226) hide show
  1. package/dist/__tests__/edge-bind-template-wiring.test.js +37 -0
  2. package/dist/__tests__/samba-provision.test.js +103 -1
  3. package/dist/index.js +19 -4
  4. package/dist/samba-provision.js +72 -0
  5. package/package.json +1 -1
  6. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +69 -19
  7. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +6 -0
  8. package/payload/platform/plugins/docs/references/admin-ui.md +43 -5
  9. package/payload/platform/plugins/docs/references/samba.md +2 -0
  10. package/payload/platform/plugins/whatsapp/references/channels-whatsapp.md +7 -3
  11. package/payload/platform/scripts/check-no-esm-require.mjs +5 -3
  12. package/payload/platform/services/claude-session-manager/dist/webchat-channel-mcp.d.ts.map +1 -1
  13. package/payload/platform/services/claude-session-manager/dist/webchat-channel-mcp.js +5 -3
  14. package/payload/platform/services/claude-session-manager/dist/webchat-channel-mcp.js.map +1 -1
  15. package/payload/platform/services/telegram-channel/dist/instructions.d.ts.map +1 -1
  16. package/payload/platform/services/telegram-channel/dist/instructions.js +13 -10
  17. package/payload/platform/services/telegram-channel/dist/instructions.js.map +1 -1
  18. package/payload/platform/services/webchat-channel/dist/instructions.d.ts +6 -2
  19. package/payload/platform/services/webchat-channel/dist/instructions.d.ts.map +1 -1
  20. package/payload/platform/services/webchat-channel/dist/instructions.js +66 -32
  21. package/payload/platform/services/webchat-channel/dist/instructions.js.map +1 -1
  22. package/payload/platform/services/webchat-channel/dist/notification.d.ts +2 -1
  23. package/payload/platform/services/webchat-channel/dist/notification.d.ts.map +1 -1
  24. package/payload/platform/services/webchat-channel/dist/notification.js +11 -4
  25. package/payload/platform/services/webchat-channel/dist/notification.js.map +1 -1
  26. package/payload/platform/services/webchat-channel/dist/server.d.ts.map +1 -1
  27. package/payload/platform/services/webchat-channel/dist/server.js +30 -14
  28. package/payload/platform/services/webchat-channel/dist/server.js.map +1 -1
  29. package/payload/platform/services/webchat-channel/dist/turn-follow.d.ts.map +1 -1
  30. package/payload/platform/services/webchat-channel/dist/turn-follow.js +11 -5
  31. package/payload/platform/services/webchat-channel/dist/turn-follow.js.map +1 -1
  32. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +6 -3
  33. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts.map +1 -1
  34. package/payload/platform/services/whatsapp-channel/dist/notification.js +9 -5
  35. package/payload/platform/services/whatsapp-channel/dist/notification.js.map +1 -1
  36. package/payload/platform/services/whatsapp-channel/dist/server.d.ts.map +1 -1
  37. package/payload/platform/services/whatsapp-channel/dist/server.js +6 -1
  38. package/payload/platform/services/whatsapp-channel/dist/server.js.map +1 -1
  39. package/payload/platform/services/whatsapp-channel/dist/targets.d.ts.map +1 -1
  40. package/payload/platform/services/whatsapp-channel/dist/targets.js +8 -2
  41. package/payload/platform/services/whatsapp-channel/dist/targets.js.map +1 -1
  42. package/payload/platform/templates/systemd/edge.service.template +1 -1
  43. package/payload/server/chunk-PXNY2TAP.js +1980 -0
  44. package/payload/server/chunk-TIIWMF5G.js +4837 -0
  45. package/payload/server/{chunk-S6HYTIU3.js → chunk-UX2FRNO7.js} +72 -2031
  46. package/payload/server/manager-B6OFOXSW.js +61 -0
  47. package/payload/server/maxy-edge.js +5 -3
  48. package/payload/server/package.json +2 -1
  49. package/payload/server/public/activity.html +5 -5
  50. package/payload/server/public/agents.html +4 -4
  51. package/payload/server/public/assets/{AdminLoginScreens-CDpNkwkf.js → AdminLoginScreens-BZPbCk8g.js} +1 -1
  52. package/payload/server/public/assets/AdminLoginScreens-BZPbCk8g.js.br +0 -0
  53. package/payload/server/public/assets/AdminLoginScreens-BZPbCk8g.js.gz +0 -0
  54. package/payload/server/public/assets/AdminShell-Bpo_wgxm.js +2 -0
  55. package/payload/server/public/assets/AdminShell-Bpo_wgxm.js.br +0 -0
  56. package/payload/server/public/assets/AdminShell-Bpo_wgxm.js.gz +0 -0
  57. package/payload/server/public/assets/{activity-BE9gv5xL.js → activity-DOsfKzT3.js} +1 -1
  58. package/payload/server/public/assets/activity-DOsfKzT3.js.br +0 -0
  59. package/payload/server/public/assets/activity-DOsfKzT3.js.gz +0 -0
  60. package/payload/server/public/assets/admin-COgD1z5a.js +1 -0
  61. package/payload/server/public/assets/admin-COgD1z5a.js.br +0 -0
  62. package/payload/server/public/assets/admin-COgD1z5a.js.gz +0 -0
  63. package/payload/server/public/assets/{agents-B-x0Ai2v.js → agents-DqDnZTH_.js} +1 -1
  64. package/payload/server/public/assets/agents-DqDnZTH_.js.br +0 -0
  65. package/payload/server/public/assets/agents-DqDnZTH_.js.gz +0 -0
  66. package/payload/server/public/assets/{browser-ZEQUkPfs.js → browser-FL17Dfp0.js} +1 -1
  67. package/payload/server/public/assets/browser-FL17Dfp0.js.br +0 -0
  68. package/payload/server/public/assets/browser-FL17Dfp0.js.gz +0 -0
  69. package/payload/server/public/assets/calendar-CLfLvsnV.js +1 -0
  70. package/payload/server/public/assets/calendar-CLfLvsnV.js.br +0 -0
  71. package/payload/server/public/assets/calendar-CLfLvsnV.js.gz +0 -0
  72. package/payload/server/public/assets/chat-DYq4FxMG.js +1 -0
  73. package/payload/server/public/assets/chat-DYq4FxMG.js.br +0 -0
  74. package/payload/server/public/assets/chat-DYq4FxMG.js.gz +0 -0
  75. package/payload/server/public/assets/chevron-left-DWt5cdUo.js +1 -0
  76. package/payload/server/public/assets/chevron-right-B9suVrYy.js +1 -0
  77. package/payload/server/public/assets/chevron-right-B9suVrYy.js.br +0 -0
  78. package/payload/server/public/assets/clock-DW6qVXtv.js +1 -0
  79. package/payload/server/public/assets/clock-DW6qVXtv.js.br +0 -0
  80. package/payload/server/public/assets/clock-DW6qVXtv.js.gz +0 -0
  81. package/payload/server/public/assets/data-C8rbOgds.js +1 -0
  82. package/payload/server/public/assets/data-C8rbOgds.js.br +0 -0
  83. package/payload/server/public/assets/data-C8rbOgds.js.gz +0 -0
  84. package/payload/server/public/assets/{file-text-BTV6EdBv.js → file-text-DNm0AzUz.js} +1 -1
  85. package/payload/server/public/assets/file-text-DNm0AzUz.js.br +0 -0
  86. package/payload/server/public/assets/file-text-DNm0AzUz.js.gz +0 -0
  87. package/payload/server/public/assets/{graph-hD_gPbbL.js → graph-fAvmlVzh.js} +3 -3
  88. package/payload/server/public/assets/graph-fAvmlVzh.js.br +0 -0
  89. package/payload/server/public/assets/graph-fAvmlVzh.js.gz +0 -0
  90. package/payload/server/public/assets/{graph-labels-CY1T19PY.js → graph-labels-CwdkCxfs.js} +1 -1
  91. package/payload/server/public/assets/graph-labels-CwdkCxfs.js.br +0 -0
  92. package/payload/server/public/assets/graph-labels-CwdkCxfs.js.gz +0 -0
  93. package/payload/server/public/assets/{maximize-2-BFV9VKHO.js → maximize-2-B6tAPhC2.js} +1 -1
  94. package/payload/server/public/assets/maximize-2-B6tAPhC2.js.br +0 -0
  95. package/payload/server/public/assets/maximize-2-B6tAPhC2.js.gz +0 -0
  96. package/payload/server/public/assets/operator-CTKvBVjk.js +1 -0
  97. package/payload/server/public/assets/operator-CTKvBVjk.js.br +0 -0
  98. package/payload/server/public/assets/operator-CTKvBVjk.js.gz +0 -0
  99. package/payload/server/public/assets/page-BWoNW3sa.js +32 -0
  100. package/payload/server/public/assets/page-BWoNW3sa.js.br +0 -0
  101. package/payload/server/public/assets/page-BWoNW3sa.js.gz +0 -0
  102. package/payload/server/public/assets/page-O5HkHQw4.js +1 -0
  103. package/payload/server/public/assets/page-O5HkHQw4.js.br +0 -0
  104. package/payload/server/public/assets/page-O5HkHQw4.js.gz +0 -0
  105. package/payload/server/public/assets/{public-D6Tnj5LU.js → public-BF1rnVPa.js} +1 -1
  106. package/payload/server/public/assets/public-BF1rnVPa.js.br +0 -0
  107. package/payload/server/public/assets/public-BF1rnVPa.js.gz +0 -0
  108. package/payload/server/public/assets/{rotate-ccw-C-RfQUza.js → rotate-ccw-B8TqfCy4.js} +1 -1
  109. package/payload/server/public/assets/rotate-ccw-B8TqfCy4.js.br +0 -0
  110. package/payload/server/public/assets/rotate-ccw-B8TqfCy4.js.gz +0 -0
  111. package/payload/server/public/assets/{routines-NIMElUzV.js → routines-4R7BLZyH.js} +1 -1
  112. package/payload/server/public/assets/routines-4R7BLZyH.js.br +0 -0
  113. package/payload/server/public/assets/routines-4R7BLZyH.js.gz +0 -0
  114. package/payload/server/public/assets/{skills-N0ZlHosx.js → skills-CQ9Qs2tg.js} +1 -1
  115. package/payload/server/public/assets/skills-CQ9Qs2tg.js.br +0 -0
  116. package/payload/server/public/assets/skills-CQ9Qs2tg.js.gz +0 -0
  117. package/payload/server/public/assets/tasks-DePyOrRk.js +1 -0
  118. package/payload/server/public/assets/tasks-DePyOrRk.js.br +0 -0
  119. package/payload/server/public/assets/tasks-DePyOrRk.js.gz +0 -0
  120. package/payload/server/public/assets/{time-entry-format-jQxlRPoI.js → time-entry-format-D9gtnG01.js} +1 -1
  121. package/payload/server/public/assets/time-entry-format-D9gtnG01.js.br +0 -0
  122. package/payload/server/public/assets/time-entry-format-D9gtnG01.js.gz +0 -0
  123. package/payload/server/public/assets/{triangle-alert-wUS4rpzv.js → triangle-alert-DAtm2mLL.js} +1 -1
  124. package/payload/server/public/assets/triangle-alert-DAtm2mLL.js.br +0 -0
  125. package/payload/server/public/assets/triangle-alert-DAtm2mLL.js.gz +0 -0
  126. package/payload/server/public/assets/{useCopyFeedback-BRix_wIl.js → useCopyFeedback-uMJT-p6i.js} +1 -1
  127. package/payload/server/public/assets/useCopyFeedback-uMJT-p6i.js.br +0 -0
  128. package/payload/server/public/assets/useCopyFeedback-uMJT-p6i.js.gz +0 -0
  129. package/payload/server/public/assets/useSubAccountSwitcher-BufCPDDH.js +14 -0
  130. package/payload/server/public/assets/useSubAccountSwitcher-BufCPDDH.js.br +0 -0
  131. package/payload/server/public/assets/useSubAccountSwitcher-BufCPDDH.js.gz +0 -0
  132. package/payload/server/public/assets/useSubAccountSwitcher-iCzsV96x.css +1 -0
  133. package/payload/server/public/assets/useSubAccountSwitcher-iCzsV96x.css.br +0 -0
  134. package/payload/server/public/assets/useSubAccountSwitcher-iCzsV96x.css.gz +0 -0
  135. package/payload/server/public/assets/useVoiceRecorder-BFrPC-s7.js +2 -0
  136. package/payload/server/public/assets/useVoiceRecorder-BFrPC-s7.js.br +0 -0
  137. package/payload/server/public/assets/useVoiceRecorder-BFrPC-s7.js.gz +0 -0
  138. package/payload/server/public/assets/{wrench-sDivAjhf.js → wrench-CYgaN7K9.js} +1 -1
  139. package/payload/server/public/assets/wrench-CYgaN7K9.js.br +1 -0
  140. package/payload/server/public/assets/wrench-CYgaN7K9.js.gz +0 -0
  141. package/payload/server/public/browser.html +4 -4
  142. package/payload/server/public/calendar.html +7 -7
  143. package/payload/server/public/chat.html +13 -13
  144. package/payload/server/public/data.html +11 -11
  145. package/payload/server/public/graph.html +9 -9
  146. package/payload/server/public/index.html +14 -14
  147. package/payload/server/public/operator.html +14 -14
  148. package/payload/server/public/public.html +13 -13
  149. package/payload/server/public/routines.html +6 -6
  150. package/payload/server/public/skills.html +5 -5
  151. package/payload/server/public/tasks.html +6 -6
  152. package/payload/server/server.js +6100 -9940
  153. package/payload/server/public/assets/AdminLoginScreens-CDpNkwkf.js.br +0 -0
  154. package/payload/server/public/assets/AdminLoginScreens-CDpNkwkf.js.gz +0 -0
  155. package/payload/server/public/assets/AdminShell-TSa8KgID.js +0 -2
  156. package/payload/server/public/assets/AdminShell-TSa8KgID.js.br +0 -0
  157. package/payload/server/public/assets/AdminShell-TSa8KgID.js.gz +0 -0
  158. package/payload/server/public/assets/activity-BE9gv5xL.js.br +0 -0
  159. package/payload/server/public/assets/activity-BE9gv5xL.js.gz +0 -0
  160. package/payload/server/public/assets/admin-Cb0BZriv.js +0 -1
  161. package/payload/server/public/assets/admin-Cb0BZriv.js.br +0 -0
  162. package/payload/server/public/assets/admin-Cb0BZriv.js.gz +0 -0
  163. package/payload/server/public/assets/agents-B-x0Ai2v.js.br +0 -0
  164. package/payload/server/public/assets/agents-B-x0Ai2v.js.gz +0 -0
  165. package/payload/server/public/assets/browser-ZEQUkPfs.js.br +0 -0
  166. package/payload/server/public/assets/browser-ZEQUkPfs.js.gz +0 -0
  167. package/payload/server/public/assets/calendar-DsRxrW0G.js +0 -1
  168. package/payload/server/public/assets/calendar-DsRxrW0G.js.br +0 -0
  169. package/payload/server/public/assets/calendar-DsRxrW0G.js.gz +0 -0
  170. package/payload/server/public/assets/chat-BS9B9lDo.js +0 -1
  171. package/payload/server/public/assets/chat-BS9B9lDo.js.br +0 -1
  172. package/payload/server/public/assets/chat-BS9B9lDo.js.gz +0 -0
  173. package/payload/server/public/assets/chevron-left-DLtGuH6K.js +0 -1
  174. package/payload/server/public/assets/chevron-right-CY_trg5L.js +0 -1
  175. package/payload/server/public/assets/chevron-right-CY_trg5L.js.br +0 -0
  176. package/payload/server/public/assets/clock-oM5driJm.js +0 -1
  177. package/payload/server/public/assets/clock-oM5driJm.js.br +0 -0
  178. package/payload/server/public/assets/clock-oM5driJm.js.gz +0 -0
  179. package/payload/server/public/assets/data-Cio6PO0F.js +0 -1
  180. package/payload/server/public/assets/data-Cio6PO0F.js.br +0 -2
  181. package/payload/server/public/assets/data-Cio6PO0F.js.gz +0 -0
  182. package/payload/server/public/assets/file-text-BTV6EdBv.js.br +0 -0
  183. package/payload/server/public/assets/file-text-BTV6EdBv.js.gz +0 -0
  184. package/payload/server/public/assets/graph-hD_gPbbL.js.br +0 -0
  185. package/payload/server/public/assets/graph-hD_gPbbL.js.gz +0 -0
  186. package/payload/server/public/assets/graph-labels-CY1T19PY.js.br +0 -0
  187. package/payload/server/public/assets/graph-labels-CY1T19PY.js.gz +0 -0
  188. package/payload/server/public/assets/maximize-2-BFV9VKHO.js.br +0 -0
  189. package/payload/server/public/assets/maximize-2-BFV9VKHO.js.gz +0 -0
  190. package/payload/server/public/assets/operator-Dxze8CwV.js +0 -1
  191. package/payload/server/public/assets/operator-Dxze8CwV.js.br +0 -0
  192. package/payload/server/public/assets/operator-Dxze8CwV.js.gz +0 -0
  193. package/payload/server/public/assets/page-Bi86QF6_.js +0 -32
  194. package/payload/server/public/assets/page-Bi86QF6_.js.br +0 -0
  195. package/payload/server/public/assets/page-Bi86QF6_.js.gz +0 -0
  196. package/payload/server/public/assets/page-BnXly9aN.js +0 -1
  197. package/payload/server/public/assets/page-BnXly9aN.js.br +0 -0
  198. package/payload/server/public/assets/page-BnXly9aN.js.gz +0 -0
  199. package/payload/server/public/assets/public-D6Tnj5LU.js.br +0 -0
  200. package/payload/server/public/assets/public-D6Tnj5LU.js.gz +0 -0
  201. package/payload/server/public/assets/rotate-ccw-C-RfQUza.js.br +0 -0
  202. package/payload/server/public/assets/rotate-ccw-C-RfQUza.js.gz +0 -0
  203. package/payload/server/public/assets/routines-NIMElUzV.js.br +0 -0
  204. package/payload/server/public/assets/routines-NIMElUzV.js.gz +0 -0
  205. package/payload/server/public/assets/skills-N0ZlHosx.js.br +0 -0
  206. package/payload/server/public/assets/skills-N0ZlHosx.js.gz +0 -0
  207. package/payload/server/public/assets/tasks-BsB_0duv.js +0 -1
  208. package/payload/server/public/assets/tasks-BsB_0duv.js.br +0 -0
  209. package/payload/server/public/assets/tasks-BsB_0duv.js.gz +0 -0
  210. package/payload/server/public/assets/time-entry-format-jQxlRPoI.js.br +0 -0
  211. package/payload/server/public/assets/time-entry-format-jQxlRPoI.js.gz +0 -0
  212. package/payload/server/public/assets/triangle-alert-wUS4rpzv.js.br +0 -0
  213. package/payload/server/public/assets/triangle-alert-wUS4rpzv.js.gz +0 -0
  214. package/payload/server/public/assets/useCopyFeedback-BRix_wIl.js.br +0 -0
  215. package/payload/server/public/assets/useCopyFeedback-BRix_wIl.js.gz +0 -0
  216. package/payload/server/public/assets/useSubAccountSwitcher-DWMWxvYo.js +0 -14
  217. package/payload/server/public/assets/useSubAccountSwitcher-DWMWxvYo.js.br +0 -0
  218. package/payload/server/public/assets/useSubAccountSwitcher-DWMWxvYo.js.gz +0 -0
  219. package/payload/server/public/assets/useSubAccountSwitcher-UQQDcj0B.css +0 -1
  220. package/payload/server/public/assets/useSubAccountSwitcher-UQQDcj0B.css.br +0 -0
  221. package/payload/server/public/assets/useSubAccountSwitcher-UQQDcj0B.css.gz +0 -0
  222. package/payload/server/public/assets/useVoiceRecorder-1TAfCP2k.js +0 -2
  223. package/payload/server/public/assets/useVoiceRecorder-1TAfCP2k.js.br +0 -0
  224. package/payload/server/public/assets/useVoiceRecorder-1TAfCP2k.js.gz +0 -0
  225. package/payload/server/public/assets/wrench-sDivAjhf.js.br +0 -0
  226. package/payload/server/public/assets/wrench-sDivAjhf.js.gz +0 -0
@@ -0,0 +1,37 @@
1
+ // Nothing else in the repo ties the edge unit template's placeholders to the
2
+ // substitutions in index.ts, so either half can be edited alone and the whole
3
+ // suite stays green. Two silent failures this closes:
4
+ //
5
+ // - substitution added, template left at a literal value → the replace is a
6
+ // no-op, the unit still binds 0.0.0.0, and the Task 2082 security fix ships
7
+ // inert with every pickEdgeBindDecision test passing;
8
+ // - template placeholder added, substitution forgotten → the unit ships a
9
+ // literal `Environment=EDGE_HOSTNAME=__EDGE_HOSTNAME__`, which edge.ts's
10
+ // `process.env.EDGE_HOSTNAME ?? '0.0.0.0'` does NOT catch, because the
11
+ // variable is present, just not an address, so server.listen() gets a
12
+ // non-address and the edge restart-loops.
13
+ //
14
+ // Both files are read from disk at runtime, the same pattern as
15
+ // macos-darwin-branch.test.ts and base-toolchain-deps.test.ts.
16
+ import test from "node:test";
17
+ import assert from "node:assert/strict";
18
+ import { readFileSync } from "node:fs";
19
+ import { fileURLToPath } from "node:url";
20
+ import { dirname, resolve } from "node:path";
21
+ // dist/__tests__/edge-bind-template-wiring.test.js → ../../src/index.ts
22
+ const here = dirname(fileURLToPath(import.meta.url));
23
+ const INDEX_TS = resolve(here, "../../src/index.ts");
24
+ // dist/__tests__ → up four is the maxy-code root that holds platform/.
25
+ const EDGE_TEMPLATE = resolve(here, "../../../../platform/templates/systemd/edge.service.template");
26
+ const SRC = readFileSync(INDEX_TS, "utf-8");
27
+ const TEMPLATE = readFileSync(EDGE_TEMPLATE, "utf-8");
28
+ test("edge template: EDGE_HOSTNAME is a placeholder, not a hardcoded address", () => {
29
+ assert.match(TEMPLATE, /^Environment=EDGE_HOSTNAME=__EDGE_HOSTNAME__$/m, "edge.service.template must take EDGE_HOSTNAME from the installer's bind decision (Task 2082), not hardcode it");
30
+ });
31
+ test("edge template: every placeholder has a matching substitution in index.ts", () => {
32
+ const placeholders = [...new Set(TEMPLATE.match(/__[A-Z0-9_]+__/g) ?? [])].sort();
33
+ assert.ok(placeholders.includes("__EDGE_HOSTNAME__"), "template must carry __EDGE_HOSTNAME__");
34
+ for (const p of placeholders) {
35
+ assert.ok(SRC.includes(`.replace(/${p}/g,`), `${p} appears in edge.service.template but index.ts has no .replace(/${p}/g, ...) — the rendered unit would ship the literal placeholder`);
36
+ }
37
+ });
@@ -6,7 +6,7 @@
6
6
  // style and runs under `node --test dist/__tests__/*.test.js` after build.
7
7
  import test from "node:test";
8
8
  import assert from "node:assert/strict";
9
- import { renderBrandStanza, renderGlobalSection, renderFullSmbConf, isPrivateIPv4, pickBindDecision, mergeSmbConf, removeBrandStanza, hasAnyBrandStanza, formatSambaMarker, SAMBA_STEPS, SAMBA_ENABLE_UNITS, } from "../samba-provision.js";
9
+ import { renderBrandStanza, renderGlobalSection, renderFullSmbConf, isPrivateIPv4, pickBindDecision, pickEdgeBindDecision, formatEdgeBindDecision, mergeSmbConf, removeBrandStanza, hasAnyBrandStanza, formatSambaMarker, SAMBA_STEPS, SAMBA_ENABLE_UNITS, } from "../samba-provision.js";
10
10
  // ---------------------------------------------------------------------------
11
11
  // Fixtures
12
12
  // ---------------------------------------------------------------------------
@@ -49,6 +49,42 @@ const MIXED_PUBLIC_AND_PRIVATE = {
49
49
  eth0: [{ address: "178.105.251.82", netmask: "255.255.255.255", family: "IPv4", mac: "96:00:00:00:00:02", internal: false, cidr: "178.105.251.82/32" }],
50
50
  wg0: [{ address: "10.8.0.3", netmask: "255.255.255.0", family: "IPv4", mac: "00:00:00:00:00:00", internal: false, cidr: "10.8.0.3/24" }],
51
51
  };
52
+ // Hetzner Cloud shape: public eth0 plus a provider private network on ens10.
53
+ // pickBindDecision calls this `lan`; the edge must still bind loopback.
54
+ const HETZNER_CLOUD_PUBLIC_PLUS_PRIVNET = {
55
+ lo: [{ address: "127.0.0.1", netmask: "255.0.0.0", family: "IPv4", mac: "00:00:00:00:00:00", internal: true, cidr: "127.0.0.1/8" }],
56
+ eth0: [{ address: "167.233.21.246", netmask: "255.255.255.255", family: "IPv4", mac: "96:00:00:00:00:03", internal: false, cidr: "167.233.21.246/32" }],
57
+ ens10: [{ address: "10.0.0.2", netmask: "255.255.0.0", family: "IPv4", mac: "96:00:00:00:00:04", internal: false, cidr: "10.0.0.2/16" }],
58
+ };
59
+ // Private wlan0 plus a public address elsewhere. Reaches pickBindDecision's
60
+ // wlan0 early return, which MIXED_PUBLIC_AND_PRIVATE never does because its
61
+ // private address is on wg0.
62
+ const PRIVATE_WLAN0_PLUS_PUBLIC = {
63
+ lo: [{ address: "127.0.0.1", netmask: "255.0.0.0", family: "IPv4", mac: "00:00:00:00:00:00", internal: true, cidr: "127.0.0.1/8" }],
64
+ wlan0: [{ address: "192.168.1.50", netmask: "255.255.255.0", family: "IPv4", mac: "aa:bb:cc:dd:ee:ff", internal: false, cidr: "192.168.1.50/24" }],
65
+ eth0: [{ address: "203.0.113.7", netmask: "255.255.255.255", family: "IPv4", mac: "96:00:00:00:00:05", internal: false, cidr: "203.0.113.7/32" }],
66
+ };
67
+ // Private eth0 plus a public address elsewhere. Reaches the eth0 early return.
68
+ const PRIVATE_ETH0_PLUS_PUBLIC = {
69
+ lo: [{ address: "127.0.0.1", netmask: "255.0.0.0", family: "IPv4", mac: "00:00:00:00:00:00", internal: true, cidr: "127.0.0.1/8" }],
70
+ eth0: [{ address: "10.0.0.5", netmask: "255.255.255.0", family: "IPv4", mac: "11:11:11:11:11:11", internal: false, cidr: "10.0.0.5/24" }],
71
+ ens5: [{ address: "198.51.100.9", netmask: "255.255.255.255", family: "IPv4", mac: "96:00:00:00:00:06", internal: false, cidr: "198.51.100.9/32" }],
72
+ };
73
+ // One interface carrying both a public and a private IPv4. No other fixture in
74
+ // this file has a multi-address interface.
75
+ const ONE_IFACE_PUBLIC_AND_PRIVATE = {
76
+ lo: [{ address: "127.0.0.1", netmask: "255.0.0.0", family: "IPv4", mac: "00:00:00:00:00:00", internal: true, cidr: "127.0.0.1/8" }],
77
+ eth0: [
78
+ { address: "192.0.2.44", netmask: "255.255.255.255", family: "IPv4", mac: "96:00:00:00:00:07", internal: false, cidr: "192.0.2.44/32" },
79
+ { address: "172.16.4.9", netmask: "255.255.0.0", family: "IPv4", mac: "96:00:00:00:00:07", internal: false, cidr: "172.16.4.9/16" },
80
+ ],
81
+ };
82
+ // CGNAT-only host. isPrivateIPv4 counts 100.64/10 as private, so this binds
83
+ // 0.0.0.0. Pinned so the outcome is declared rather than incidental.
84
+ const CGNAT_ONLY = {
85
+ lo: [{ address: "127.0.0.1", netmask: "255.0.0.0", family: "IPv4", mac: "00:00:00:00:00:00", internal: true, cidr: "127.0.0.1/8" }],
86
+ eth0: [{ address: "100.100.3.14", netmask: "255.192.0.0", family: "IPv4", mac: "96:00:00:00:00:08", internal: false, cidr: "100.100.3.14/10" }],
87
+ };
52
88
  // ---------------------------------------------------------------------------
53
89
  // isPrivateIPv4 — RFC1918 / CGNAT / link-local classification
54
90
  // ---------------------------------------------------------------------------
@@ -107,6 +143,72 @@ test("pickBindDecision: none when the only non-loopback iface has no IPv4", () =
107
143
  assert.deepEqual(pickBindDecision(PI_IPV6_ONLY_LAN), { kind: "none" });
108
144
  });
109
145
  // ---------------------------------------------------------------------------
146
+ // pickEdgeBindDecision — 0.0.0.0 only when private exists and public does not
147
+ // ---------------------------------------------------------------------------
148
+ test("pickEdgeBindDecision: private-only hosts keep the public bind", () => {
149
+ for (const [name, ifaces] of [
150
+ ["PI_WLAN0_ONLY", PI_WLAN0_ONLY],
151
+ ["PI_BOTH_IFACES", PI_BOTH_IFACES],
152
+ ["PI_ETH0_ONLY", PI_ETH0_ONLY],
153
+ ["PI_USB0_FALLBACK", PI_USB0_FALLBACK],
154
+ ["CGNAT_ONLY", CGNAT_ONLY],
155
+ ]) {
156
+ assert.equal(pickEdgeBindDecision(ifaces).hostname, "0.0.0.0", name);
157
+ }
158
+ });
159
+ // The fail-open this task exists to close: pickBindDecision calls every one of
160
+ // these `lan`, so keeping its verdict verbatim would leave the edge public on a
161
+ // cloud box that happens to carry a provider private network.
162
+ test("pickEdgeBindDecision: a public IPv4 forces loopback even when a private one exists", () => {
163
+ for (const [name, ifaces] of [
164
+ ["MIXED_PUBLIC_AND_PRIVATE", MIXED_PUBLIC_AND_PRIVATE],
165
+ ["HETZNER_CLOUD_PUBLIC_PLUS_PRIVNET", HETZNER_CLOUD_PUBLIC_PLUS_PRIVNET],
166
+ ["PRIVATE_WLAN0_PLUS_PUBLIC", PRIVATE_WLAN0_PLUS_PUBLIC],
167
+ ["PRIVATE_ETH0_PLUS_PUBLIC", PRIVATE_ETH0_PLUS_PUBLIC],
168
+ ["ONE_IFACE_PUBLIC_AND_PRIVATE", ONE_IFACE_PUBLIC_AND_PRIVATE],
169
+ ]) {
170
+ const d = pickEdgeBindDecision(ifaces);
171
+ assert.equal(d.hostname, "127.0.0.1", name);
172
+ assert.equal(d.kind, "lan", `${name} still classifies as lan for smbd`);
173
+ }
174
+ });
175
+ test("pickEdgeBindDecision: public-only host binds loopback", () => {
176
+ const d = pickEdgeBindDecision(HETZNER_PUBLIC_ETH0_ONLY);
177
+ assert.equal(d.hostname, "127.0.0.1");
178
+ assert.equal(d.kind, "loopback-only");
179
+ });
180
+ // Unlike the Samba call site, which throws on `none`, the edge binds loopback
181
+ // and lets the install finish: a loopback edge still serves the tunnel.
182
+ test("pickEdgeBindDecision: no non-loopback IPv4 binds loopback rather than throwing", () => {
183
+ for (const ifaces of [PI_LO_ONLY, PI_IPV6_ONLY_LAN]) {
184
+ const d = pickEdgeBindDecision(ifaces);
185
+ assert.equal(d.hostname, "127.0.0.1");
186
+ assert.equal(d.kind, "none");
187
+ assert.deepEqual(d.privateAddresses, []);
188
+ assert.deepEqual(d.publicAddresses, []);
189
+ }
190
+ });
191
+ test("pickEdgeBindDecision: reports the interface and addresses it decided from", () => {
192
+ assert.deepEqual(pickEdgeBindDecision(MIXED_PUBLIC_AND_PRIVATE), {
193
+ hostname: "127.0.0.1",
194
+ kind: "lan",
195
+ iface: "wg0",
196
+ publicAddresses: ["178.105.251.82"],
197
+ privateAddresses: ["10.8.0.3"],
198
+ });
199
+ assert.deepEqual(pickEdgeBindDecision(PI_WLAN0_ONLY), {
200
+ hostname: "0.0.0.0",
201
+ kind: "lan",
202
+ iface: "wlan0",
203
+ publicAddresses: [],
204
+ privateAddresses: ["192.168.1.50"],
205
+ });
206
+ });
207
+ test("formatEdgeBindDecision: carries both grep tokens and never leaves an empty field", () => {
208
+ assert.equal(formatEdgeBindDecision(pickEdgeBindDecision(PI_WLAN0_ONLY)), "[edge-bind-decision] kind=lan iface=wlan0 private=192.168.1.50 public=none EDGE_HOSTNAME=0.0.0.0");
209
+ assert.equal(formatEdgeBindDecision(pickEdgeBindDecision(HETZNER_PUBLIC_ETH0_ONLY)), "[edge-bind-decision] kind=loopback-only iface=none private=none public=167.233.21.246 EDGE_HOSTNAME=127.0.0.1");
210
+ });
211
+ // ---------------------------------------------------------------------------
110
212
  // renderBrandStanza — exact spec match
111
213
  // ---------------------------------------------------------------------------
112
214
  test("renderBrandStanza emits the spec directives in order", () => {
package/dist/index.js CHANGED
@@ -29,7 +29,7 @@ import { classifyPortHolder } from "./preflight-port-classifier.js";
29
29
  import { memoryControllerMissing, cmdlineAlreadyPatched, patchCmdline } from "./cgroup-memory-controller.js";
30
30
  import { parsePluginList, computeInstallActions, parseExternalPlugins, findUnregisteredResyncs, discoverMarketplaceCandidateDirs, } from "./lib/plugin-install.js";
31
31
  import { findPremiumMcpDirs } from "./lib/premium-mcp-discover.js";
32
- import { pickBindDecision, mergeSmbConf, formatSambaMarker, SAMBA_ENABLE_UNITS, } from "./samba-provision.js";
32
+ import { pickBindDecision, pickEdgeBindDecision, formatEdgeBindDecision, mergeSmbConf, formatSambaMarker, SAMBA_ENABLE_UNITS, } from "./samba-provision.js";
33
33
  import { networkInterfaces, userInfo, cpus } from "node:os";
34
34
  const PAYLOAD_DIR = resolve(import.meta.dirname, "../payload");
35
35
  // Brand manifest — read from payload to derive all brand-specific installation values.
@@ -4493,11 +4493,20 @@ WantedBy=default.target
4493
4493
  // VNC_DISPLAY (defined above for the main brand unit) is also stamped into
4494
4494
  // the edge unit so both services agree on the X display. Per-brand display
4495
4495
  // closes the cross-brand cookie leak documented.
4496
+ // Task 2082 — the edge binds 0.0.0.0 only on a host with a private IPv4 and
4497
+ // no public one; anything else binds loopback so a rented server never puts
4498
+ // the admin edge on the internet. Logged with its inputs because a wrong
4499
+ // classification is otherwise invisible until an external probe finds it.
4500
+ // Linux-only by construction: this function returns at the darwin branch
4501
+ // above, and darwin runs no edge process at all.
4502
+ const edgeBind = pickEdgeBindDecision(networkInterfaces());
4503
+ logFile(` ${formatEdgeBindDecision(edgeBind)}`);
4496
4504
  const edgeTemplatePath = resolve(INSTALL_DIR, "platform/templates/systemd/edge.service.template");
4497
4505
  if (existsSync(edgeTemplatePath)) {
4498
4506
  const edgeServiceContent = readFileSync(edgeTemplatePath, "utf-8")
4499
4507
  .replace(/__INSTALL_DIR__/g, INSTALL_DIR)
4500
4508
  .replace(/__EDGE_PORT__/g, String(PORT))
4509
+ .replace(/__EDGE_HOSTNAME__/g, edgeBind.hostname)
4501
4510
  .replace(/__MAXY_UI_PORT__/g, String(MAXY_UI_INTERNAL_PORT))
4502
4511
  .replace(/__PERSIST_DIR__/g, persistDir)
4503
4512
  .replace(/__VNC_DISPLAY__/g, String(VNC_DISPLAY))
@@ -4505,7 +4514,7 @@ WantedBy=default.target
4505
4514
  .replace(/__CDP_PORT__/g, String(CDP_PORT_BRAND))
4506
4515
  .replace(/__RFB_PORT__/g, String(RFB_PORT));
4507
4516
  writeFileSync(join(serviceDir, edgeUnitName), edgeServiceContent);
4508
- logFile(` ${edgeUnitName}: EDGE_PORT=${PORT} MAXY_UI_PORT=${MAXY_UI_INTERNAL_PORT} VNC_DISPLAY=:${VNC_DISPLAY} RFB_PORT=${RFB_PORT} WEBSOCKIFY_PORT=${WEBSOCKIFY_PORT_BRAND} CDP_PORT=${CDP_PORT_BRAND}`);
4517
+ logFile(` ${edgeUnitName}: EDGE_PORT=${PORT} EDGE_HOSTNAME=${edgeBind.hostname} MAXY_UI_PORT=${MAXY_UI_INTERNAL_PORT} VNC_DISPLAY=:${VNC_DISPLAY} RFB_PORT=${RFB_PORT} WEBSOCKIFY_PORT=${WEBSOCKIFY_PORT_BRAND} CDP_PORT=${CDP_PORT_BRAND}`);
4509
4518
  }
4510
4519
  else {
4511
4520
  console.error(` WARNING: edge.service.template missing at ${edgeTemplatePath} — VNC transport unavailable`);
@@ -5389,8 +5398,14 @@ try {
5389
5398
  console.log("================================================================");
5390
5399
  console.log("");
5391
5400
  if (isLinux()) {
5392
- console.log(` Same network (Pi / LAN): http://${DEVICE_HOSTNAME}.local:${PORT}`);
5393
- console.log("");
5401
+ // Only true when the edge binds 0.0.0.0. On a host classified to loopback
5402
+ // (Task 2082) nothing answers on the LAN address, so printing this line
5403
+ // there would be a false instruction; the SSH-forward recipe below is the
5404
+ // whole story for that host class.
5405
+ if (pickEdgeBindDecision(networkInterfaces()).hostname === "0.0.0.0") {
5406
+ console.log(` Same network (Pi / LAN): http://${DEVICE_HOSTNAME}.local:${PORT}`);
5407
+ console.log("");
5408
+ }
5394
5409
  console.log(` Remote access (Hetzner / cloud) — on your local machine run:`);
5395
5410
  console.log(` ssh -L ${PORT}:localhost:${PORT} -L ${BRAND.websockifyPort}:localhost:${BRAND.websockifyPort} admin@<server-ipv4>`);
5396
5411
  console.log(` Then open:`);
@@ -14,6 +14,11 @@
14
14
  // stanza intact (peer-brand isolation is the structural invariant).
15
15
  // 5. Report whether any brand stanza remains so the uninstall step can
16
16
  // decide whether to apt-purge samba.
17
+ //
18
+ // Despite the file name, the interface classification here serves two
19
+ // consumers, not one: `isPrivateIPv4` and `pickBindDecision` answer where smbd
20
+ // binds, and `pickEdgeBindDecision` (Task 2082) answers where the edge binds.
21
+ // The two deliberately disagree on a dual-homed host; see that function.
17
22
  // ---------------------------------------------------------------------------
18
23
  // Brand-scoped Samba stanza
19
24
  // ---------------------------------------------------------------------------
@@ -152,6 +157,73 @@ export function pickBindDecision(ifaces) {
152
157
  const hasNonLoopbackIPv4 = Object.keys(ifaces).some((name) => name !== "lo" && ipv4Addrs(name).length > 0);
153
158
  return hasNonLoopbackIPv4 ? { kind: "loopback-only" } : { kind: "none" };
154
159
  }
160
+ /**
161
+ * Decide how the edge should bind, from the same interface table
162
+ * `pickBindDecision` reads. The edge binds `0.0.0.0` only when the host has at
163
+ * least one private IPv4 AND zero public IPv4; every other shape binds
164
+ * `127.0.0.1`. Task 2082.
165
+ *
166
+ * Three properties are deliberate, not oversights:
167
+ *
168
+ * 1. This DISAGREES with `pickBindDecision` on a dual-homed host. For SMB, a
169
+ * private interface alongside a public one keeps the LAN bind (operator
170
+ * confirmation, Task 755 — see the MIXED_PUBLIC_AND_PRIVATE fixture). For
171
+ * the edge the operator decided the opposite: a cloud server carrying a
172
+ * provider private network (Hetzner Cloud's public `eth0` plus private
173
+ * `ens10`) must not silently keep a public bind. Both functions are right
174
+ * for their own consumer; this is not a rename of the other.
175
+ * 2. IPv6 is ignored. `pickBindDecision` already filters to IPv4, and
176
+ * `0.0.0.0` binds IPv4 only, so a public IPv6 address cannot be exposed by
177
+ * this bind under either outcome.
178
+ * 3. A cloud host presenting its public address by 1:1 NAT is NOT covered.
179
+ * Its guest NIC carries only a private address, so this reads a
180
+ * private-only host and binds `0.0.0.0` while the box is publicly
181
+ * reachable. The scoped signal is a public IPv4 alongside the private one;
182
+ * no host in `.docs/install/` behaves that way, and catching it would need
183
+ * an outbound probe rather than the interface table.
184
+ *
185
+ * `kind: "none"` binds `127.0.0.1` rather than throwing, unlike the Samba call
186
+ * site, which hard-fails: a share bound to nothing is useless, whereas a
187
+ * loopback edge still serves the Cloudflare tunnel, which dials
188
+ * `http://localhost:<port>`. Cost: a host that gains an interface later stays
189
+ * loopback-only until the installer is re-run, which the decision line makes
190
+ * visible in the install log.
191
+ */
192
+ export function pickEdgeBindDecision(ifaces) {
193
+ const bind = pickBindDecision(ifaces);
194
+ const publicAddresses = [];
195
+ const privateAddresses = [];
196
+ for (const [name, addrs] of Object.entries(ifaces)) {
197
+ if (name === "lo" || !addrs)
198
+ continue;
199
+ for (const a of addrs) {
200
+ if (a.family !== "IPv4" || a.internal)
201
+ continue;
202
+ if (isPrivateIPv4(a.address))
203
+ privateAddresses.push(a.address);
204
+ else
205
+ publicAddresses.push(a.address);
206
+ }
207
+ }
208
+ return {
209
+ hostname: privateAddresses.length > 0 && publicAddresses.length === 0 ? "0.0.0.0" : "127.0.0.1",
210
+ kind: bind.kind,
211
+ iface: bind.kind === "lan" ? bind.lanInterface : null,
212
+ publicAddresses,
213
+ privateAddresses,
214
+ };
215
+ }
216
+ /**
217
+ * The install-log line for an edge bind decision. The shape is load-bearing:
218
+ * the task's diagnostic path greps `bind-decision` and `EDGE_HOSTNAME`, so both
219
+ * tokens must survive any edit. Empty lists render as the literal `none` so a
220
+ * host with no addresses is distinguishable from a truncated line.
221
+ */
222
+ export function formatEdgeBindDecision(d) {
223
+ const list = (xs) => (xs.length === 0 ? "none" : xs.join(","));
224
+ return `[edge-bind-decision] kind=${d.kind} iface=${d.iface ?? "none"} ` +
225
+ `private=${list(d.privateAddresses)} public=${list(d.publicAddresses)} EDGE_HOSTNAME=${d.hostname}`;
226
+ }
155
227
  // ---------------------------------------------------------------------------
156
228
  // smb.conf merge / remove
157
229
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-sitedesk-code",
3
- "version": "0.1.518",
3
+ "version": "0.1.519",
4
4
  "description": "Install SiteDesk — automated back office for independent building contractors",
5
5
  "bin": {
6
6
  "create-sitedesk-code": "./dist/index.js"
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: platform-architecture
3
3
  description: Use when grounding any documented-surface claim about what SiteDesk ships — plugins, skills, specialists, install/deploy flows, internals. This is the install catalogue, not evidence of what is enabled on the current account. For install state on this account, call `capabilities-here`; for documented surface, cite the `Source:` URL inline.
4
- content-hash: sha256:f7196d99cda8a64e13d1951d4363a879f9141949c174d6d26810b0a47a2f4ee8
4
+ content-hash: sha256:69ee9681cbe3bd78fa8d38f916aa5979d4814681b59f3c1bd3570c613315ca7c
5
5
  brand: sitedesk-code
6
6
  product-name: SiteDesk
7
7
  ---
@@ -1213,7 +1213,7 @@ npx -y @rubytech/create-realagent-code@latest --uninstall
1213
1213
  - **No SCP / rsync.** Updates are `npx -y @rubytech/create-maxy-code@latest …` again, never a file push from the operator's laptop.
1214
1214
  - **Tunnel auth is OAuth; the API is permitted for the rest.** The tunnel's only auth path is `cloudflared tunnel login` in the noVNC browser over SSH forward. DNS, Pages, and D1 use the Cloudflare API with a short-lived narrow token the agent mints from an operator-provisioned master token (see the `cloudflare` plugin).
1215
1215
  - **No shared state across brands.** Two brands on one server each have their own Neo4j port, systemd unit, VNC display, websockify port, tunnel, and persist directory.
1216
- - **No public IPv4 exposure.** The Hetzner firewall opens port 22 only; every operator-facing surface is fronted by the Cloudflare tunnel.
1216
+ - **No public IPv4 exposure.** The Hetzner firewall opens port 22 only; every operator-facing surface is fronted by the Cloudflare tunnel. The provider firewall is no longer the only thing making that true for the admin edge: this host carries a public IPv4, so the installer writes `EDGE_HOSTNAME=127.0.0.1` into the edge unit and the edge port is loopback-only by construction. That holds whether or not the box also has a Cloud Network attached. It covers the edge port only; see [netcup.md](netcup.md) for what a host without a provider firewall still depends on `ufw` for.
1217
1217
 
1218
1218
  ## Smoke checklist
1219
1219
 
@@ -1294,7 +1294,7 @@ In netcup's Customer Control Panel:
1294
1294
  2. When the server is provisioned, install **Ubuntu 24.04 LTS** from the CCP's image list.
1295
1295
  3. Add your SSH public key during image setup, or paste it into `/root/.ssh/authorized_keys` from the remote console afterwards.
1296
1296
 
1297
- netcup does not provide a network firewall product in front of the server, so the host firewall is the only inbound control. Close everything except SSH before going further:
1297
+ netcup does not provide a network firewall product in front of the server, so at this point the host firewall is the only inbound control. Close everything except SSH before going further:
1298
1298
 
1299
1299
  ```bash
1300
1300
  ufw default deny incoming
@@ -1369,7 +1369,7 @@ npx -y @rubytech/create-realagent-code@latest --hostname <realagent-hostname>
1369
1369
 
1370
1370
  The persist directory becomes `$HOME/.realagent-code`, the systemd user-service becomes `<realagent-hostname>.service`, Neo4j is provisioned on its own port, and the VNC display + websockify + ttyd ports shift to the brand's reserved range.
1371
1371
 
1372
- Co-residency has an aggregate cost the per-brand capacity figure does not express: each brand's daemon reads whole-box RAM independently, so four brands each resolving 10 sessions can exceed what the box holds. On a 64 GB host this has more headroom than on 16 GB, but it is not unlimited.
1372
+ Co-residency has an aggregate cost the per-brand capacity figure does not express, and clearing the 56 GiB step makes it sharper, not safer. Each brand's daemon reads whole-box RAM independently and reaches its own conclusion, so on a 64 GB host **four co-resident brands each resolve 40**, an aggregate of 160 concurrent sessions at roughly 1.2 GB each against a box that holds 64 GB. No brand reaches its own cap and is wrong; the box runs out first. A count-based per-daemon cap derived from box-wide RAM cannot express a constraint shared by co-resident daemons the box-wide `MemAvailable` reclaim in `slice-memory-policy.ts` is what actually bounds this. Treat a multi-brand 64 GB install as needing that reclaim path verified under load, not assumed.
1373
1373
 
1374
1374
  ## 5. Reach the dashboard and VNC browser over SSH port-forwarding
1375
1375
 
@@ -1411,21 +1411,31 @@ This stops and disables the systemd user-service, removes the unit file, removes
1411
1411
  - **No SCP / rsync.** Updates are `npx -y @rubytech/create-maxy-code@latest …` again, never a file push from the operator's laptop.
1412
1412
  - **Tunnel auth is OAuth; the API is permitted for the rest.** As on Hetzner.
1413
1413
  - **No shared state across brands.** Two brands on one server each have their own Neo4j port, systemd unit, VNC display, websockify port, tunnel, and persist directory.
1414
- - **No provider firewall.** Unlike Hetzner Cloud, inbound control is the host `ufw` rules from step 1 alone. Verify with `ufw status` after install; the installer does not open ports.
1414
+ - **No provider firewall, so `ufw` is the only network-level layer.** On Hetzner Cloud the provider firewall opens 22 only; netcup has nothing in front of the server. What keeps the admin edge private here is the bind itself: the installer classifies the host from its interface table and, because this box carries a public IPv4, writes `EDGE_HOSTNAME=127.0.0.1` into the edge unit, so the edge port is loopback-only by construction and `ufw` is the backstop rather than the single control. Confirm which was written with `grep -E 'bind-decision|EDGE_HOSTNAME' $HOME/.<brand>/logs/install-*.log`. This covers the **edge port only**, not the whole box: the noVNC websockify transport still binds all interfaces, so `ufw` remains load-bearing for that port, and anything that clears the ruleset — a flushed nftables set after a kernel change, `ufw disable` during debugging, a CCP image reinstall — exposes it. Verify from **off** the box, not with `ufw status` on it.
1415
1415
 
1416
1416
  ## Smoke checklist
1417
1417
 
1418
1418
  Fresh-netcup smoke pass criteria:
1419
1419
 
1420
- 1. Provision an RS 8000 G12 with Ubuntu 24.04 x86_64 and an SSH key; `ufw status` reports SSH only; SSH in as `root`, create `admin`, switch.
1421
- 2. Snapshot taken with the OS prepared and nothing installed.
1422
- 3. Install completes on the clean image, prints a loopback URL, and the systemd user-service is `active (running)`.
1423
- 4. `logs-read.sh --tail server 200 | grep 'op=capacity-resolved'` returns one line, and its `totalMemGiB` matches the provisioned RAM.
1424
- 5. The noVNC page reached over `ssh -L` displays the admin UI.
1425
- 6. Cloudflare setup ends with `curl -I https://<hostname>.<your-zone>` returning `HTTP/2 200` from the operator's laptop.
1426
- 7. Reboot; both units come back up; the public URL is reachable again without any manual action.
1427
- 8. Install a second brand with a different `--hostname`; both admin UIs are reachable on their own public hostnames and neither has touched the other's state.
1428
- 9. Uninstall removes the systemd unit and the persist directory.
1420
+ 1. Provision an RS 8000 G12 with Ubuntu 24.04 x86_64 and an SSH key; SSH in as `root`, create `admin`, switch.
1421
+ 2. **From a machine off the server**, port 22 answers and the edge port does not. Two independent things make that true here: the edge binds loopback on this host class, and `ufw` denies inbound. `ufw status` on the box states the intention; only an external probe confirms the outcome:
1422
+
1423
+ ```bash
1424
+ nc -z -w 5 <ipv4> 22 && echo "22 open (expected)"
1425
+ nc -z -w 5 <ipv4> 19200 && echo "EDGE EXPOSED check the bind, then ufw"
1426
+ ```
1427
+
1428
+ If the edge port answers, read the bind first: `grep -E 'bind-decision|EDGE_HOSTNAME' $HOME/.<brand>/logs/install-*.log` should show `EDGE_HOSTNAME=127.0.0.1` on this host, and `ss -lntp` should show the edge on `127.0.0.1:19200`. A public bind there is a classification fault, not a firewall fault.
1429
+
1430
+ Re-run this after every reboot, after any brand install, and after any CCP image reinstall.
1431
+ 3. Snapshot taken with the OS prepared and nothing installed.
1432
+ 4. Install completes on the clean image, prints a loopback URL, and the systemd user-service is `active (running)`.
1433
+ 5. `logs-read.sh --tail server 200 | grep 'op=capacity-resolved'` returns one line reporting `capacity=40`, with `totalMemGiB` matching the provisioned RAM. A `capacity=10` on a 64 GB box means the host resolved below the 56 GiB step and needs investigating before load.
1434
+ 6. The noVNC page reached over `ssh -L` displays the admin UI.
1435
+ 7. Cloudflare setup ends with `curl -I https://<hostname>.<your-zone>` returning `HTTP/2 200` from the operator's laptop.
1436
+ 8. Reboot; both units come back up; the public URL is reachable again without any manual action; the step-2 external probe still shows the edge port closed.
1437
+ 9. Install a second brand with a different `--hostname`; both admin UIs are reachable on their own public hostnames and neither has touched the other's state.
1438
+ 10. Uninstall removes the systemd unit and the persist directory.
1429
1439
 
1430
1440
  If any step fails, attach `$HOME/.<brand>/logs/install-<timestamp>.log` to the report.
1431
1441
 
@@ -3257,7 +3267,7 @@ either is a regression.
3257
3267
 
3258
3268
  **`/chat` Claude-desktop transcript presentation.** The admin webchat keeps the shared `Transcript` shell (stream, follow-tail) but injects a /chat-only item renderer via the component's optional `renderItems` prop: `renderChatTimeline` in `app/chat/transcript-render.tsx`. Presentation: operator turns render as a right-aligned grey bubble showing the message text and, beneath it, a subtle always-visible time-of-day stamp (no label); delivered agent replies render as plain prose with the same time-of-day stamp beneath (the reply-document filename line stays, as prose); the stamp is HH:MM from the turn's `ts` (locale-formatted, right-aligned inside the operator bubble, left-aligned under agent prose), and a turn whose `ts` is null/unparseable shows no stamp, never an empty line — so only delivered operator and agent-reply turns are stamped, while tool runs, the collapsed "Thinking" block, and the agent-error banner stay time-free; a maximal consecutive run of `tool-call`/`tool-result` turns renders as one collapsed grey one-liner ("Used N tools ›") whose expansion shows every call and result payload — a lone call still gets the one-liner, and prose or a directive row ends a run. The WhatsApp/operator reader (`/whatsapp`, `OperatorConversations`) omits the prop and keeps the default `renderTimeline`, so its bubble/DM chrome is unchanged — except that since the attachment-render extraction the default renderer draws inbound `operator-inbound` uploads and `agent-file` deliveries inline (image thumbnail / pdf.js preview / voice-note player / download chip) via the attachment renderer shared with `/chat`, extracted to `app/chat/attachment-render.tsx` to break the cycle with `transcript-render.tsx`; `renderChatTimeline` is untouched. Both presentations are test-pinned (`app/whatsapp/__tests__/Transcript-*.test.tsx`, `app/chat/__tests__/transcript-render.test.tsx`, `app/chat/__tests__/pdf-preview.test.tsx`). **Day-divider:** both renderers insert a centered `.day-divider` row between two consecutive timeline items on different local calendar days (label `Today`/`Yesterday`, else `Sat 14 Jun 2026`), so a thread spanning midnight is never an ambiguous run of HH:MM; the first dated item gets a leading divider, a null/unparseable `ts` marks no boundary, dividers are computed over the filtered `visibleItems`, and in `/chat` a day crossover flushes any open tool/think run first so a collapsed run never spans it. Per-bubble HH:MM stamps are unchanged. Shared helpers `dayKey`/`dayLabel`/`itemTs`/`DayDivider` live in `app/whatsapp/Transcript.tsx`.
3259
3269
 
3260
- **One delivered bubble per turn when the agent replied.** On the admin `/chat`, a turn that used the reply tool shows exactly one delivered bubble — the reply. A trailing sign-off or recap the agent writes after the reply is treated as narration and is not shown as a second bubble (it collapses like any mid-turn prose). Only a turn that ends **without** using the reply tool has its final text promoted to a delivered bubble, so a genuine no-reply answer is never lost. The tradeoff, accepted by the operator: a turn that acknowledges on the reply tool and then writes the real answer as trailing text will not show that trailing answer the agent is steered to put the answer on the reply tool so this stays rare.
3270
+ **One delivered bubble per turn on the admin `/chat`.** The admin webchat has no outbound transport, so the agent has no reply tool there: the text its turn ends with **is** the delivered answer, and it renders as the one prominent prose bubble. Mid-turn prose collapses into the folded run as before, so a turn shows exactly one delivered bubble. A turn that reaches its end with no trailing text delivers nothing and is logged as `op=turn-answer-absent`. Public webchat, WhatsApp and Telegram keep their reply tool, and every historical admin transcript still renders its recorded reply-tool bubbles unchanged.
3261
3271
 
3262
3272
  **WhatsApp reader is read/write (WhatsApp-Web surface).** A WhatsApp store conversation in the operator Conversations reader (`OperatorConversations`) and the admin root channel viewer (`app/page.tsx`) no longer opens the plain read-only DM chrome — it opens `app/whatsapp/WhatsappWebConversation.tsx`, a WhatsApp-Web-styled surface: a conversation header, the delivered-only store transcript reskinned green/white under a `.wa-web` root class, and a reply composer (`app/whatsapp/ReplyComposer.tsx`). The operator can type text, insert emoji (a codepoint-data picker — no OS input, so the `check-no-emoji-in-jsx` gate stays green), record a voice note (MediaRecorder → the existing audio send path), attach a file **from device storage** (a modal browsing the DATA_ROOT tree via `GET /api/admin/files`, sent by reference — the OS file dialog is no longer the reader's picker), reply to a specific message (a per-bubble affordance that quotes the original in the composer and threads natively on WhatsApp), and search the loaded transcript (a header control that filters to matching turns and highlights the match). The header carries only that Search control — the former Video / Phone / menu icons are gone, because no call or presence data exists behind them. Sent files honour the webchat allowlist (`SUPPORTED_MIME_SET` / 50 MB / 5 files); a device-file reference is authorised server-side (`authorizeReplyFileRef`) against the send's own account (the conversation owner) **or** the session's pinned account (`getAccountIdForSession` — the exact scope the DeviceFilePicker used to offer it), so a house/managing-admin session can attach a managed-client file the picker listed. Because a reply sends bytes OUT to an external WhatsApp recipient, any other account, a `public` partition, and any install-wide / non-account path are refused fail-closed; the cross-account admission's authority is the session's pinned account, never the caller-supplied body `accountId` (the own-account admission matches the body `accountId`, which the route already trusts via `isValidAccountId` and the existing-conversation guard). A scope refusal returns a distinct HTTP 403 reason (`file-ref-cross-account` / `file-ref-public` / `file-ref-foreign`) and copy, never "device file not found" (reserved for a genuine resolve/stat miss). Per-ref `[wa-manual-send] op=reply-file-ref … via=own|session|deny reason=<…> ok=<bool>` logs the decision before any send; `via=deny ok=true` is the exfiltration signature that must never appear. **Send is reply-only and DM-only:** `POST /api/whatsapp-reader/reply` (`requireAdminSession`) refuses any target with no existing stored conversation (`reason=no-existing-conversation`, HTTP 403) — a cold first-contact send bans the account, so the composer is never a cold-contact path, enforced server-side — and refuses `@g.us` groups (`reason=group-send-blocked`), matching the agent's observe-only group invariant; a group thread shows a read-only note in place of the composer. The endpoint is the sole store writer for a manual send: right after each send resolves it appends the record itself, stamped `origin:'operator-manual'` (attributed "You", distinct from an assistant reply), because the Baileys echo that normally persists the store is gated on message text (`manager.ts:906`) and would leave an uncaptioned media send delivered-but-invisible. The record's `messageId` matches the echo's derivation so the async text echo's own persist dedups to a no-op; the send is confirmed by re-read (`op=recorded present=<bool>`). A sent send does not spawn an assistant turn (echo dispatch stays suppressed). Attachments deliver as WhatsApp media/documents and render **inline** in the store reader (image thumbnail / pdf preview / audio player / download chip), because the manual-send path records the message with the servable `attachmentId`. The `.wa-web` skin is scoped to WhatsApp store threads only, so `/chat`, the admin agent session view, and the public visitor webchat are unchanged; the visitor webchat gains no send capability. The in-bubble delivered-tick is cosmetic (no read-receipt data is stored); inbound bubbles keep their sender name so group threads stay attributable, and both directions carry a WhatsApp callout tail (`.wa-web`-scoped only). Non-WhatsApp reader rows (Telegram/webchat session JSONLs) keep the plain read-only `Transcript`. Observability rides `[wa-manual-send]` lines correlated by a per-request `sendId`. **Composer sizing matches the webchat composer:** the reply textarea auto-grows to fit multi-line content (measured from `scrollHeight`, clamped to a one-row minimum and a `viewport/3` maximum, scrollable past the cap with the scrollbar hidden), and a top-border grab handle drags (pointer) or nudges (ArrowUp/ArrowDown, keyboard-focusable) to an explicit height in the same clamp; a manual drag pins the height and overrides auto-grow until the field is cleared, which releases it back to auto-grow. A completed manual resize logs `[operator-ui] op=wa-composer-resize mode=drag|key h=<px>`; a bare click on the handle (no drag) logs nothing.
3263
3273
 
@@ -3935,7 +3945,7 @@ authoritative. This section names the surfaces and what backs each.
3935
3945
  > a read-only reader. The item gates on the flyout handler being wired, exactly
3936
3946
  > like the admin and lite items. The read-only inbound-channel **Conversations**
3937
3947
  > reader (WhatsApp, Telegram, public webchat, via the admin-gated
3938
- > `/api/whatsapp-reader/conversations` route) remains on `/data`'s Conversations
3948
+ > `/api/operator-conversations/conversations` route) remains on `/data`'s Conversations
3939
3949
  > item and in the admin Sidebar; it is no longer reachable from the operator chat
3940
3950
  > surface. The operator surface is documented in full in the internal doc
3941
3951
  > `maxy-code/.docs/operator-dashboard.md`.
@@ -3977,7 +3987,7 @@ authoritative. This section names the surfaces and what backs each.
3977
3987
  > count. On the operator **chat** surface the handler is always wired, so the
3978
3988
  > item is always present and opens the live session switcher (above). On `/data`
3979
3989
  > the handler is wired only when ≥1 reviewable inbound conversation exists
3980
- > (fetched from `/whatsapp-reader/conversations` with the operator's session
3990
+ > (fetched from `/operator-conversations/conversations` with the operator's session
3981
3991
  > key), and selecting it renders the read-only reader in place (the file browser
3982
3992
  > is swapped for it; Back returns) — so `/data` keeps its hide-when-empty
3983
3993
  > behaviour. The flyout-open signal `[operator-ui] op=conversations-menu
@@ -3991,8 +4001,8 @@ authoritative. This section names the surfaces and what backs each.
3991
4001
  | Nav rows (Chat / People / Agents / Projects / Tasks / Artefacts) | People, Agents, Tasks open the artefact-pane Graph filtered to the matching label. Chat selects the active conversation. Artefacts swaps the list to editable documents. **The "Projects" row is brand-aware:** its visible term and the graph label it filters to both come from the injected `window.__BRAND__.primaryContainer` (`brand.json` → `server/index.ts` inject → `brand.ts` `brandPrimaryContainer()`), so SiteDesk reads "Projects" → `:Project`, SiteDesk "Jobs" → `:Job`, Real Agent "Properties" → `:Property`. A brand JSON missing `primaryContainer` falls back to Project/Projects (server logs `[brand] op=primaryContainer-default reason=absent brand=<hostname>`; the populated case logs `[brand] op=inject primaryContainer=<label>/<term>`). | `graph-search.ts`, `graph-subgraph.ts`, `sidebar-artefacts.ts` |
3992
4002
  | Sessions list (Active / Archived / All) | Live row store driven by SSE; manual reconcile button on the segmented control re-fetches the full id set. Every listed row carries two launch actions: the claude.ai/code resume (`ExternalLink`) and a chat-launch (`MessageSquare`) that opens `/chat?session=<full sessionId>` — the admin webchat pointed at that exact session; sending there resumes a stopped session with the webchat channel bound to that id (see `admin-webchat-native-channel.md`, "Session-targeted mode"). **Clicking the row's body** (live-dot, title, id, or timestamp) opens that same `/chat?session=<id>` as the chat-launch action. The main area stays a `<div>` (it wraps the inline rename input, which a `<button>` may not contain) carrying `role="button"`, `tabIndex`, an "Open <title> in webchat" aria-label, and Enter/Space activation, styled with `conv-main-clickable` for pointer + hover parity with the channel-row button. The click and keyboard handlers no-op while the row is being renamed, so an in-progress edit is preserved; the action icons and the `⋯` overflow menu are a sibling cluster running their own handlers, so activating them never triggers the row-body open. At the ≤720 px drawer breakpoint each row stacks into two lines — dot + title + id/stamp, then the action icons — with a divider between rows and actions at full opacity (the desktop hover-gated 0.5 opacity never resolves on touch). Below a 400 px list width each row's action icons collapse to a single `MoreHorizontal` ellipsis trigger; its popover menu lists the same actions as the inline icons but labels them with concise verbs (`Resume in claude.ai/code`, `Open in webchat`, `Stop`, `Archive` / `Unarchive`, `Rename`, `Delete`, `Re-seat`) rather than the inline icon buttons' verbose per-session aria-labels — the inline buttons keep those verbose labels because an icon-only control needs the full descriptive accessible name. **Re-seat:** Re-seat is a member action of the one `SessionRowActions` cluster, not a separate sibling control — wide it is the sliders icon inline with the other icons (sharing the cluster geometry and alignment); collapsed it is the `Re-seat` menu item, which expands the model/mode/effort form inline inside the overflow menu. The form (`SessionReseatControl`) forks the session via `/api/admin/session-reseat` and navigates to the fork (the same fork mechanism `/chat`'s pickers drive); its model picker defaults to the row's current model (a `model` field on each row, read from the JSONL tail), and mode and effort each carry a "Keep" option that omits the field. The overflow menu and the Re-seat form render through a `document.body` portal, fixed-positioned from the trigger's rect (right-aligned, flipped above the trigger when there is no room below), so neither is clipped by `.side-list`'s `overflow-y:auto`; Escape, a pointerdown outside the trigger/popover, and any scroll or resize dismiss them. | `/claude-sessions/events`, `/claude-sessions`, `/session-reseat` |
3993
4003
  | Data pane | A "Data" nav row beside Artefacts/Sessions. Uses the same `activeNav` toggle, but its content — the headless `DataFileBrowser` (graph search + `{installDir}/data/` file browser) — renders in the shell's **main column** via `DataPaneSurface`, like the WhatsApp Transcript, not in the side-list; there is no side-list block under `activeNav==='data'`. `AdminShell` owns a `dataOpen` main-column state (mutually exclusive with the WhatsApp selection); the row lifts the choice through `onSelectData`. On `/` it opens in place; from any other route (`/graph`, `/chat`, `/browser`) it navigates to `/?data=1`, which the root shell hydrates on mount (`hydrateDataFromUrl`, then strips the query). The admin burger no longer carries a Data item (the operator surface, which has no Sidebar, keeps the burger → `/data` route). Client breadcrumbs: `[admin-ui] sidebar-nav surface=data …`, `[admin-ui] data-open route=… via=<in-place\|navigate>`, `[admin-ui] data-hydrate route=/`. | `sidebar-artefacts.ts`, `files.ts`, `graph-search.ts` |
3994
- | Channel reader nav rows (WhatsApp / Telegram) | One conditional nav row per channel that has ≥1 conversation. Each row carries its brand glyph (WhatsApp `#25D366`, Telegram `#229ED9`); a channel with no conversations shows no row, and no install ever shows an always-empty "No conversations" row. The channel list loads **once on sidebar mount**, so a failed load shows one muted, non-clickable line instead of channel rows. **WhatsApp rows are sourced from the on-disk message store** (`readAllConversations`), one row per stored conversation keyed by `remoteJid`, independent of whether any session JSONL exists — a blocked non-admin DM or a group-observed thread that never produced a session is listed here. **Telegram rows are session-sourced** (one per admin Telegram session), unchanged. Each conversation row shows a **display name**: for WhatsApp, the store record's `senderName` (else the `senderTelephone`, else the `remoteJid`); for Telegram, the precedence `operatorName ?? senderId ?? title` where `operatorName` is the bound `Person givenName familyName`. Under the name each row shows a **last-message-time breadcrumb** (`conv-timestamp`) from `lastMessageAt`, never "Invalid Date". **Scope colour (WhatsApp):** each WhatsApp row is coloured by its conversation's scope — a thin left accent bar, green for `admin`, orange for `public`, taken from the conversation's most-recent stored record (this replaces the former "Public" text pill). **Re-seat:** only session-sourced rows (Telegram, and webchat in the Sessions panel) carry a `SessionRowActions` Re-seat cluster; a WhatsApp store row has no session, so it carries no Re-seat. A click on a conversation **opens its transcript on the home surface**: on `/` it selects in place, and from any other route it navigates to `/?wa=<remoteJid>&acct=<accountId>`, which the root shell hydrates on mount (then strips the query). **Account-scoped** exactly as the sidebar Sessions list: both row sources (WhatsApp store rows and Telegram/public-webchat session rows) are scoped to the caller's pinned account (`getAccountIdForSession(cacheKey)`), multi-account-gated (`listValidAccounts().length > 1`). On a multi-account install a subaccount view shows only its own conversations; the house account's WhatsApp appears only under the house view; an untagged session sidecar is excluded and counted; an unresolvable scope fails closed to an empty list (logs `op=list-scope-unresolved`). A single-account install filters nothing, byte-identical to before. Server logs `[wa-reader] op=list pinnedAccount=<id8> resolvedAccount=<id8> multiAccount=<bool> conversations=<n> sessionRows=<m> storeRows=<k> adminScope=<n> publicScope=<n> sessionRowsExcludedUntagged=<n>` per list build (`pinnedAccount === resolvedAccount` is the post-fix invariant when `multiAccount=true`; `conversations=0` on an account with a non-empty store dir is the over-filtering drift signal); the client logs `[admin-ui] wa-open route=… via=<in-place\|navigate> …` and `[admin-ui] wa-hydrate route=/ remoteJid=…`. | `/whatsapp-reader/conversations` |
3995
- | Channel transcript (`<Transcript>`) | **WhatsApp** reads its conversation verbatim from the message store file (`<remoteJid>.jsonl`) over SSE (`/whatsapp-reader/store-stream`): both directions, `dateSent`-ordered, backlog on connect then a live tail so a newly stored message appears without reload — no session JSONL is read. **Telegram** still reads one session's JSONL over SSE (`/whatsapp-reader/stream`) and renders both sides plus tool calls — the turns claude.ai/code hides because channel inbound is stamped `isMeta`. Every turn shows its time-of-day (HH:MM from the turn's `ts`; a null/unparseable `ts` shows no time, never "Invalid Date"). On the Telegram session stream, `tool-call`/`tool-result` turns render as **collapsed cards** so the human↔agent text is not buried in JSON. The thread **follows the tail**: it opens at the newest turn and pins to the bottom on each live append **only while the operator is already within 32 px of the bottom**; while scrolled up a **jump-to-bottom control** (`.wa-jump`) appears. Both streams send a 20 s heartbeat so an idle Cloudflare tunnel does not idle-drop, tag each frame with a byte-offset `id:`, and resume from the client's `Last-Event-ID` on reconnect (no duplicate backlog); the client clears the "Stream disconnected" banner on reopen and latches it only on a terminal CLOSED state. The Telegram session stream also interleaves a collapsed `⚙ directive injected (N B)` row per turn (listed by `GET /whatsapp-reader/directives?sessionId=`, bytes served by `GET /whatsapp-reader/directive?sessionId=&name=`); a WhatsApp store conversation has no session, so it has no directives. A centered day-divider row (`.day-divider`) marks each local calendar-day crossover. This viewer renders **delivered messages only** (both WhatsApp store rows and Telegram session rows, via `forceDeliveredOnly`) with **no per-viewer toggle** — the former "Messages only" pill is retired; the admin `/chat` composer's own messages-only control is unaffected. The panel — wallpaper included — is **bounded to `--chat-measure` and centred** (`.platform > .wa-reader`), so it reads as a column the same width as `/data`, not an edge-to-edge beige surface. The store stream open logs `[wa-reader] op=open remoteJid=<jid> scope=<admin\|public> messages=<n> source=store`. | `/whatsapp-reader/store-stream`, `/whatsapp-reader/stream`, `/whatsapp-reader/directives`, `/whatsapp-reader/directive` |
4004
+ | Channel reader nav rows (WhatsApp / Telegram) | One conditional nav row per channel that has ≥1 conversation. Each row carries its brand glyph (WhatsApp `#25D366`, Telegram `#229ED9`); a channel with no conversations shows no row, and no install ever shows an always-empty "No conversations" row. The channel list loads **once on sidebar mount**, so a failed load shows one muted, non-clickable line instead of channel rows. **WhatsApp rows are sourced from the on-disk message store** (`readAllConversations`), one row per stored conversation keyed by `remoteJid`, independent of whether any session JSONL exists — a blocked non-admin DM or a group-observed thread that never produced a session is listed here. **Telegram rows are session-sourced** (one per admin Telegram session), unchanged. Each conversation row shows a **display name**: for WhatsApp, the store record's `senderName` (else the `senderTelephone`, else the `remoteJid`); for Telegram, the precedence `operatorName ?? senderId ?? title` where `operatorName` is the bound `Person givenName familyName`. Under the name each row shows a **last-message-time breadcrumb** (`conv-timestamp`) from `lastMessageAt`, never "Invalid Date". **Scope colour (WhatsApp):** each WhatsApp row is coloured by its conversation's scope — a thin left accent bar, green for `admin`, orange for `public`, taken from the conversation's most-recent stored record (this replaces the former "Public" text pill). **Re-seat:** only session-sourced rows (Telegram, and webchat in the Sessions panel) carry a `SessionRowActions` Re-seat cluster; a WhatsApp store row has no session, so it carries no Re-seat. A click on a conversation **opens its transcript on the home surface**: on `/` it selects in place, and from any other route it navigates to `/?wa=<remoteJid>&acct=<accountId>`, which the root shell hydrates on mount (then strips the query). **Account-scoped** exactly as the sidebar Sessions list: both row sources (WhatsApp store rows and Telegram/public-webchat session rows) are scoped to the caller's pinned account (`getAccountIdForSession(cacheKey)`), multi-account-gated (`listValidAccounts().length > 1`). On a multi-account install a subaccount view shows only its own conversations; the house account's WhatsApp appears only under the house view; an untagged session sidecar is excluded and counted; an unresolvable scope fails closed to an empty list (logs `op=list-scope-unresolved`). A single-account install filters nothing, byte-identical to before. Server logs `[wa-reader] op=list pinnedAccount=<id8> resolvedAccount=<id8> multiAccount=<bool> conversations=<n> sessionRows=<m> storeRows=<k> adminScope=<n> publicScope=<n> sessionRowsExcludedUntagged=<n>` per list build (`pinnedAccount === resolvedAccount` is the post-fix invariant when `multiAccount=true`; `conversations=0` on an account with a non-empty store dir is the over-filtering drift signal); the client logs `[admin-ui] wa-open route=… via=<in-place\|navigate> …` and `[admin-ui] wa-hydrate route=/ remoteJid=…`. | `/operator-conversations/conversations` |
4005
+ | Channel transcript (`<Transcript>`) | **WhatsApp** reads its conversation verbatim from the message store file (`<remoteJid>.jsonl`) over SSE (`/whatsapp-reader/store-stream`): both directions, `dateSent`-ordered, backlog on connect then a live tail so a newly stored message appears without reload — no session JSONL is read. **Telegram** still reads one session's JSONL over SSE (`/session-reader/stream`) and renders both sides plus tool calls — the turns claude.ai/code hides because channel inbound is stamped `isMeta`. Every turn shows its time-of-day (HH:MM from the turn's `ts`; a null/unparseable `ts` shows no time, never "Invalid Date"). On the Telegram session stream, `tool-call`/`tool-result` turns render as **collapsed cards** so the human↔agent text is not buried in JSON. The thread **follows the tail**: it opens at the newest turn and pins to the bottom on each live append **only while the operator is already within 32 px of the bottom**; while scrolled up a **jump-to-bottom control** (`.wa-jump`) appears. Both streams send a 20 s heartbeat so an idle Cloudflare tunnel does not idle-drop, tag each frame with a byte-offset `id:`, and resume from the client's `Last-Event-ID` on reconnect (no duplicate backlog); the client clears the "Stream disconnected" banner on reopen and latches it only on a terminal CLOSED state. The Telegram session stream also interleaves a collapsed `⚙ directive injected (N B)` row per turn (listed by `GET /session-reader/directives?sessionId=`, bytes served by `GET /session-reader/directive?sessionId=&name=`); a WhatsApp store conversation has no session, so it has no directives. A centered day-divider row (`.day-divider`) marks each local calendar-day crossover. This viewer renders **delivered messages only** (both WhatsApp store rows and Telegram session rows, via `forceDeliveredOnly`) with **no per-viewer toggle** — the former "Messages only" pill is retired; the admin `/chat` composer's own messages-only control is unaffected. The panel — wallpaper included — is **bounded to `--chat-measure` and centred** (`.platform > .wa-reader`), so it reads as a column the same width as `/data`, not an edge-to-edge beige surface. The store stream open logs `[wa-reader] op=open remoteJid=<jid> scope=<admin\|public> messages=<n> source=store`. | `/whatsapp-reader/store-stream`, `/session-reader/stream`, `/session-reader/directives`, `/session-reader/directive` |
3996
4006
  | Conversations row hover actions | Inline rename, archive, delete, JSONL view / download per row. The historical `.conversations-modal` CSS block exists in `globals.css` but is no longer mounted from any TSX — Sidebar.tsx now owns every per-row affordance directly. | `claude-sessions.ts` |
3997
4007
  | Artefacts list | Lists every `:FileArtifact` under this account's tree (`relativePath STARTS WITH 'accounts/'`, all file types, excluding the `uploads/<id>/` subtree) plus this account's IDENTITY / SOUL / KNOWLEDGE / specialist templates. Click downloads the row's backing file (`downloadPath` → `GET /api/admin/files/download`) so the operator opens it in their local app; rows whose file is outside `DATA_ROOT` (bundled-fallback templates) show a "can't be downloaded" pill. The in-app artefact pane is dead pending removal. | `sidebar-artefacts.ts`, `files.ts` |
3998
4008
  | System-stats widget | CPU / RAM widget at the foot of the sidebar. | `system-stats.ts` (see above) |
@@ -4094,6 +4104,44 @@ equivalent wording: the reader reply path and the `/send-admin` route.
4094
4104
  Consolidating them is tracked separately; do not copy their strings into
4095
4105
  new callers.
4096
4106
 
4107
+ ## WhatsApp inbound notifications
4108
+
4109
+ An inbound WhatsApp message raises a WhatsApp-green card at the **top right** of
4110
+ every admin surface within about a second of arrival. The card shows the
4111
+ sub-account it arrived on, the sender (their WhatsApp profile name, or their
4112
+ number when they have none), and the first two lines of the message, with an
4113
+ ellipsis when there was more. A media message with no caption shows its media
4114
+ type instead.
4115
+
4116
+ The card disappears within five seconds and is **stored nowhere** — there is no
4117
+ history, no unread count, and nothing to click. It exists to tell the operator a
4118
+ message landed; the reader is where the message itself is read.
4119
+
4120
+ **Bursts drain rather than pile up.** One card alone lasts five seconds. As more
4121
+ arrive, every card on screen speeds up: two share 2.5 seconds each, three 1.7,
4122
+ four 1.25, and five or more sit on a 1.2 second floor. A card's dwell only ever
4123
+ shortens, so it does not linger once the stack empties. There is no cap on how
4124
+ many cards can appear at once.
4125
+
4126
+ **Four sender types, one icon each:**
4127
+
4128
+ | Icon | Sender |
4129
+ |---|---|
4130
+ | Shield | **Admin** — the operator's own number, or a bound admin |
4131
+ | Two-way arrows | **Account manager (active)** — a manager whose messages drive an agent turn |
4132
+ | Eye | **Account manager (passive)** — a passive bind, which files work but never replies |
4133
+ | Person | **Public** — a member of the public |
4134
+
4135
+ Cards from **every** sub-account appear regardless of which account the tab is
4136
+ currently pinned to, and each is labelled with that sub-account's name. That is
4137
+ deliberate: seeing another account's message is what tells the operator to
4138
+ switch to it.
4139
+
4140
+ Messages that raise no card, by design: anything the operator sent, group
4141
+ messages (groups are observation-only), history catch-up during a re-sync, and
4142
+ any sender the access rules decline. A blocked or unrecognised contact is
4143
+ silent.
4144
+
4097
4145
  ## Related references
4098
4146
 
4099
4147
  - [`platform.md`](platform.md) — UI layout, session reconcile model,
@@ -5402,6 +5450,8 @@ So:
5402
5450
 
5403
5451
  The share is reachable solely from the box itself. Operators reach it by forwarding loopback over SSH — `ssh -L 4445:localhost:445 admin@<tunnel-host>` and then mounting `smb://localhost:4445` — or by joining the box to a private network and re-installing, which makes the private interface the chosen LAN bind.
5404
5452
 
5453
+ That second remedy applies to SMB only. The admin edge uses a different rule: it binds loopback whenever the host carries a public IPv4, whether or not a private interface exists alongside it, so joining a private network does not make the edge LAN-reachable on a cloud box.
5454
+
5405
5455
  If the device has no non-loopback IPv4 interface at all, the installer refuses to provision and exits — there is nothing safe to bind to. This is distinct from the public-only case, which still provisions a working (host-local) share.
5406
5456
 
5407
5457
  This classification is the structural guarantee that SMB never leaves the box (loopback-only) or the LAN (LAN-only), even if upstream firewall rules are misconfigured. The Cloudflare tunnel that fronts the admin UI carries HTTPS only; it does not route SMB.
@@ -9,6 +9,12 @@ Invoked by the admin agent directly.
9
9
 
10
10
  This is the platform's release timeline, newest first. Each entry shows the date it shipped and the version it shipped in, so you can tell the operator how current their install is. To compare, read the installed version from `capabilities-here` and match it against the versions below. Keep answers high level and in plain English; this is a summary, not a full commit log.
11
11
 
12
+ ## 2026-07-28 (0.1.519)
13
+
14
+ - The admin webchat now delivers the assistant's answer as the turn's final text, and the separate reply tool has been retired for admin sessions (public visitors still get it).
15
+ - The Edge tunnel now binds to the correct interface on hosts without a LAN, and the port is documented as private by construction with ufw as the backstop.
16
+ - WhatsApp group messages in the reader show sender colours and avatars.
17
+
12
18
  ## 2026-07-28 (0.1.518)
13
19
 
14
20
  - WhatsApp sender numbers are now shown in full international form instead of a bare string of digits.