@rubytech/create-sitedesk-code 0.1.518 → 0.1.520

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 (346) 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/docs/superpowers/plans/2026-07-28-task-2097-email-signature-set.md +65 -0
  7. package/payload/platform/docs/superpowers/specs/2026-07-28-task-2097-email-signature-set-design.md +155 -0
  8. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +69 -19
  9. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +12 -0
  10. package/payload/platform/plugins/cloudflare/bin/portal-assemble.mjs +187 -0
  11. package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +46 -5
  12. package/payload/platform/plugins/cloudflare/mcp/__tests__/auth-route.test.ts +19 -7
  13. package/payload/platform/plugins/cloudflare/mcp/__tests__/authorize.test.ts +154 -38
  14. package/payload/platform/plugins/cloudflare/mcp/__tests__/file-routes.test.ts +100 -29
  15. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-assemble.test.ts +143 -0
  16. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-delete-withdraw.test.ts +12 -5
  17. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts +15 -3
  18. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +105 -11
  19. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +15 -2
  20. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-moved-download.test.ts +9 -5
  21. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-schema-shape.test.ts +47 -5
  22. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-session-grant.test.ts +26 -2
  23. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-target.test.ts +12 -4
  24. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-upload-multipart.test.ts +23 -15
  25. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-upload-target.test.ts +23 -15
  26. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-uploads-listing.test.ts +8 -0
  27. package/payload/platform/plugins/cloudflare/mcp/__tests__/skill-contract.test.ts +23 -6
  28. package/payload/platform/plugins/cloudflare/mcp/__tests__/upload-route.test.ts +36 -20
  29. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +85 -22
  30. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +47 -7
  31. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/authorize.ts +27 -18
  32. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/ratelimit.ts +10 -3
  33. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/session.ts +27 -11
  34. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/target.ts +18 -9
  35. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/types.ts +16 -0
  36. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/auth.ts +16 -4
  37. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/delete.ts +26 -11
  38. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +14 -10
  39. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +13 -9
  40. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/upload.ts +42 -22
  41. package/payload/platform/plugins/cloudflare/skills/data-portal/template/wrangler.toml +17 -4
  42. package/payload/platform/plugins/docs/references/admin-ui.md +43 -5
  43. package/payload/platform/plugins/docs/references/samba.md +2 -0
  44. package/payload/platform/plugins/email/.claude-plugin/plugin.json +1 -1
  45. package/payload/platform/plugins/email/PLUGIN.md +20 -1
  46. package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-list-tool.test.d.ts +2 -0
  47. package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-list-tool.test.d.ts.map +1 -0
  48. package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-list-tool.test.js +142 -0
  49. package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-list-tool.test.js.map +1 -0
  50. package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-list.test.d.ts +2 -0
  51. package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-list.test.d.ts.map +1 -0
  52. package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-list.test.js +153 -0
  53. package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-list.test.js.map +1 -0
  54. package/payload/platform/plugins/email/mcp/dist/__tests__/email-signature-set.test.d.ts +2 -0
  55. package/payload/platform/plugins/email/mcp/dist/__tests__/email-signature-set.test.d.ts.map +1 -0
  56. package/payload/platform/plugins/email/mcp/dist/__tests__/email-signature-set.test.js +203 -0
  57. package/payload/platform/plugins/email/mcp/dist/__tests__/email-signature-set.test.js.map +1 -0
  58. package/payload/platform/plugins/email/mcp/dist/index.js +69 -0
  59. package/payload/platform/plugins/email/mcp/dist/index.js.map +1 -1
  60. package/payload/platform/plugins/email/mcp/dist/lib/compose.d.ts.map +1 -1
  61. package/payload/platform/plugins/email/mcp/dist/lib/compose.js +5 -7
  62. package/payload/platform/plugins/email/mcp/dist/lib/compose.js.map +1 -1
  63. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts +42 -0
  64. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts.map +1 -1
  65. package/payload/platform/plugins/email/mcp/dist/lib/imap.js +76 -0
  66. package/payload/platform/plugins/email/mcp/dist/lib/imap.js.map +1 -1
  67. package/payload/platform/plugins/email/mcp/dist/lib/signature.d.ts +38 -0
  68. package/payload/platform/plugins/email/mcp/dist/lib/signature.d.ts.map +1 -1
  69. package/payload/platform/plugins/email/mcp/dist/lib/signature.js +67 -2
  70. package/payload/platform/plugins/email/mcp/dist/lib/signature.js.map +1 -1
  71. package/payload/platform/plugins/email/mcp/dist/tools/email-draft-list.d.ts +10 -0
  72. package/payload/platform/plugins/email/mcp/dist/tools/email-draft-list.d.ts.map +1 -0
  73. package/payload/platform/plugins/email/mcp/dist/tools/email-draft-list.js +73 -0
  74. package/payload/platform/plugins/email/mcp/dist/tools/email-draft-list.js.map +1 -0
  75. package/payload/platform/plugins/email/mcp/dist/tools/email-signature-set.d.ts +18 -0
  76. package/payload/platform/plugins/email/mcp/dist/tools/email-signature-set.d.ts.map +1 -0
  77. package/payload/platform/plugins/email/mcp/dist/tools/email-signature-set.js +38 -0
  78. package/payload/platform/plugins/email/mcp/dist/tools/email-signature-set.js.map +1 -0
  79. package/payload/platform/plugins/email/references/email-reference.md +14 -2
  80. package/payload/platform/plugins/email/skills/email-composition/SKILL.md +10 -0
  81. package/payload/platform/plugins/email/skills/email-signature/SKILL.md +61 -0
  82. package/payload/platform/plugins/scheduling/PLUGIN.md +8 -0
  83. package/payload/platform/plugins/scheduling/mcp/dist/index.js +26 -15
  84. package/payload/platform/plugins/scheduling/mcp/dist/index.js.map +1 -1
  85. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/schedule-audit.test.d.ts +2 -0
  86. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/schedule-audit.test.d.ts.map +1 -0
  87. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/schedule-audit.test.js +48 -0
  88. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/schedule-audit.test.js.map +1 -0
  89. package/payload/platform/plugins/scheduling/mcp/dist/lib/schedule-audit.d.ts +24 -0
  90. package/payload/platform/plugins/scheduling/mcp/dist/lib/schedule-audit.d.ts.map +1 -0
  91. package/payload/platform/plugins/scheduling/mcp/dist/lib/schedule-audit.js +33 -0
  92. package/payload/platform/plugins/scheduling/mcp/dist/lib/schedule-audit.js.map +1 -0
  93. package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js +22 -1
  94. package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js.map +1 -1
  95. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-cancel-meeting.test.d.ts +2 -0
  96. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-cancel-meeting.test.d.ts.map +1 -0
  97. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-cancel-meeting.test.js +30 -0
  98. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-cancel-meeting.test.js.map +1 -0
  99. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-get-meeting.test.d.ts +2 -0
  100. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-get-meeting.test.d.ts.map +1 -0
  101. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-get-meeting.test.js +43 -0
  102. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-get-meeting.test.js.map +1 -0
  103. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-list.test.d.ts +2 -0
  104. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-list.test.d.ts.map +1 -0
  105. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-list.test.js +87 -0
  106. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-list.test.js.map +1 -0
  107. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-update-meeting.test.d.ts +2 -0
  108. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-update-meeting.test.d.ts.map +1 -0
  109. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-update-meeting.test.js +48 -0
  110. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-update-meeting.test.js.map +1 -0
  111. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-cancel.d.ts.map +1 -1
  112. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-cancel.js +14 -1
  113. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-cancel.js.map +1 -1
  114. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-get.d.ts +4 -1
  115. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-get.d.ts.map +1 -1
  116. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-get.js +39 -2
  117. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-get.js.map +1 -1
  118. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-list.d.ts +4 -1
  119. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-list.d.ts.map +1 -1
  120. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-list.js +50 -1
  121. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-list.js.map +1 -1
  122. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-update.d.ts.map +1 -1
  123. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-update.js +43 -1
  124. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-update.js.map +1 -1
  125. package/payload/platform/plugins/whatsapp/references/channels-whatsapp.md +7 -3
  126. package/payload/platform/scripts/check-no-esm-require.mjs +5 -3
  127. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
  128. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +2 -0
  129. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
  130. package/payload/platform/services/claude-session-manager/dist/webchat-channel-mcp.d.ts.map +1 -1
  131. package/payload/platform/services/claude-session-manager/dist/webchat-channel-mcp.js +5 -3
  132. package/payload/platform/services/claude-session-manager/dist/webchat-channel-mcp.js.map +1 -1
  133. package/payload/platform/services/telegram-channel/dist/instructions.d.ts.map +1 -1
  134. package/payload/platform/services/telegram-channel/dist/instructions.js +13 -10
  135. package/payload/platform/services/telegram-channel/dist/instructions.js.map +1 -1
  136. package/payload/platform/services/webchat-channel/dist/instructions.d.ts +6 -2
  137. package/payload/platform/services/webchat-channel/dist/instructions.d.ts.map +1 -1
  138. package/payload/platform/services/webchat-channel/dist/instructions.js +66 -32
  139. package/payload/platform/services/webchat-channel/dist/instructions.js.map +1 -1
  140. package/payload/platform/services/webchat-channel/dist/notification.d.ts +2 -1
  141. package/payload/platform/services/webchat-channel/dist/notification.d.ts.map +1 -1
  142. package/payload/platform/services/webchat-channel/dist/notification.js +11 -4
  143. package/payload/platform/services/webchat-channel/dist/notification.js.map +1 -1
  144. package/payload/platform/services/webchat-channel/dist/server.d.ts.map +1 -1
  145. package/payload/platform/services/webchat-channel/dist/server.js +30 -14
  146. package/payload/platform/services/webchat-channel/dist/server.js.map +1 -1
  147. package/payload/platform/services/webchat-channel/dist/turn-follow.d.ts.map +1 -1
  148. package/payload/platform/services/webchat-channel/dist/turn-follow.js +11 -5
  149. package/payload/platform/services/webchat-channel/dist/turn-follow.js.map +1 -1
  150. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +6 -3
  151. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts.map +1 -1
  152. package/payload/platform/services/whatsapp-channel/dist/notification.js +9 -5
  153. package/payload/platform/services/whatsapp-channel/dist/notification.js.map +1 -1
  154. package/payload/platform/services/whatsapp-channel/dist/server.d.ts.map +1 -1
  155. package/payload/platform/services/whatsapp-channel/dist/server.js +6 -1
  156. package/payload/platform/services/whatsapp-channel/dist/server.js.map +1 -1
  157. package/payload/platform/services/whatsapp-channel/dist/targets.d.ts.map +1 -1
  158. package/payload/platform/services/whatsapp-channel/dist/targets.js +8 -2
  159. package/payload/platform/services/whatsapp-channel/dist/targets.js.map +1 -1
  160. package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -1
  161. package/payload/platform/templates/systemd/edge.service.template +1 -1
  162. package/payload/server/chunk-PXNY2TAP.js +1980 -0
  163. package/payload/server/chunk-TIIWMF5G.js +4837 -0
  164. package/payload/server/{chunk-S6HYTIU3.js → chunk-UX2FRNO7.js} +72 -2031
  165. package/payload/server/manager-B6OFOXSW.js +61 -0
  166. package/payload/server/maxy-edge.js +5 -3
  167. package/payload/server/package.json +2 -1
  168. package/payload/server/public/activity.html +5 -5
  169. package/payload/server/public/agents.html +4 -4
  170. package/payload/server/public/assets/AdminLoginScreens-SW7mEMcP.js +1 -0
  171. package/payload/server/public/assets/AdminLoginScreens-SW7mEMcP.js.br +0 -0
  172. package/payload/server/public/assets/AdminLoginScreens-SW7mEMcP.js.gz +0 -0
  173. package/payload/server/public/assets/AdminShell-Djg6ndne.js +2 -0
  174. package/payload/server/public/assets/AdminShell-Djg6ndne.js.br +0 -0
  175. package/payload/server/public/assets/AdminShell-Djg6ndne.js.gz +0 -0
  176. package/payload/server/public/assets/{activity-BE9gv5xL.js → activity-sqbyX0pX.js} +1 -1
  177. package/payload/server/public/assets/activity-sqbyX0pX.js.br +0 -0
  178. package/payload/server/public/assets/activity-sqbyX0pX.js.gz +0 -0
  179. package/payload/server/public/assets/admin-D9Bt47GY.js +1 -0
  180. package/payload/server/public/assets/admin-D9Bt47GY.js.br +0 -0
  181. package/payload/server/public/assets/admin-D9Bt47GY.js.gz +0 -0
  182. package/payload/server/public/assets/{agents-B-x0Ai2v.js → agents-BFYM5EDA.js} +1 -1
  183. package/payload/server/public/assets/agents-BFYM5EDA.js.br +0 -0
  184. package/payload/server/public/assets/agents-BFYM5EDA.js.gz +0 -0
  185. package/payload/server/public/assets/{browser-ZEQUkPfs.js → browser-DP5GZe4G.js} +1 -1
  186. package/payload/server/public/assets/browser-DP5GZe4G.js.br +0 -0
  187. package/payload/server/public/assets/browser-DP5GZe4G.js.gz +0 -0
  188. package/payload/server/public/assets/calendar-BKaemLDG.js +1 -0
  189. package/payload/server/public/assets/calendar-BKaemLDG.js.br +0 -0
  190. package/payload/server/public/assets/calendar-BKaemLDG.js.gz +0 -0
  191. package/payload/server/public/assets/chat-DPhT6urD.js +1 -0
  192. package/payload/server/public/assets/chat-DPhT6urD.js.br +1 -0
  193. package/payload/server/public/assets/chat-DPhT6urD.js.gz +0 -0
  194. package/payload/server/public/assets/chevron-left-BSdXvJcH.js +1 -0
  195. package/payload/server/public/assets/chevron-right-DI7vI3IB.js +1 -0
  196. package/payload/server/public/assets/chevron-right-DI7vI3IB.js.br +0 -0
  197. package/payload/server/public/assets/clock-YUTPXZH5.js +1 -0
  198. package/payload/server/public/assets/clock-YUTPXZH5.js.br +0 -0
  199. package/payload/server/public/assets/clock-YUTPXZH5.js.gz +0 -0
  200. package/payload/server/public/assets/data-CfjttTvR.js +1 -0
  201. package/payload/server/public/assets/data-CfjttTvR.js.br +2 -0
  202. package/payload/server/public/assets/data-CfjttTvR.js.gz +0 -0
  203. package/payload/server/public/assets/{file-text-BTV6EdBv.js → file-text-BKK7R-k_.js} +1 -1
  204. package/payload/server/public/assets/file-text-BKK7R-k_.js.br +0 -0
  205. package/payload/server/public/assets/file-text-BKK7R-k_.js.gz +0 -0
  206. package/payload/server/public/assets/{graph-hD_gPbbL.js → graph-FuxYySjK.js} +3 -3
  207. package/payload/server/public/assets/graph-FuxYySjK.js.br +0 -0
  208. package/payload/server/public/assets/graph-FuxYySjK.js.gz +0 -0
  209. package/payload/server/public/assets/{graph-labels-CY1T19PY.js → graph-labels-Cip6fmKK.js} +1 -1
  210. package/payload/server/public/assets/graph-labels-Cip6fmKK.js.br +0 -0
  211. package/payload/server/public/assets/graph-labels-Cip6fmKK.js.gz +0 -0
  212. package/payload/server/public/assets/{maximize-2-BFV9VKHO.js → maximize-2-D79AhVDo.js} +1 -1
  213. package/payload/server/public/assets/maximize-2-D79AhVDo.js.br +0 -0
  214. package/payload/server/public/assets/maximize-2-D79AhVDo.js.gz +0 -0
  215. package/payload/server/public/assets/operator-BPAfgFhP.js +1 -0
  216. package/payload/server/public/assets/operator-BPAfgFhP.js.br +0 -0
  217. package/payload/server/public/assets/operator-BPAfgFhP.js.gz +0 -0
  218. package/payload/server/public/assets/page--78CbdFs.js +32 -0
  219. package/payload/server/public/assets/page--78CbdFs.js.br +0 -0
  220. package/payload/server/public/assets/page--78CbdFs.js.gz +0 -0
  221. package/payload/server/public/assets/page-BqEyS0W4.js +1 -0
  222. package/payload/server/public/assets/page-BqEyS0W4.js.br +0 -0
  223. package/payload/server/public/assets/page-BqEyS0W4.js.gz +0 -0
  224. package/payload/server/public/assets/{public-D6Tnj5LU.js → public-D1xs3FzD.js} +1 -1
  225. package/payload/server/public/assets/public-D1xs3FzD.js.br +0 -0
  226. package/payload/server/public/assets/public-D1xs3FzD.js.gz +0 -0
  227. package/payload/server/public/assets/{rotate-ccw-C-RfQUza.js → rotate-ccw-CcU_XD8B.js} +1 -1
  228. package/payload/server/public/assets/rotate-ccw-CcU_XD8B.js.br +0 -0
  229. package/payload/server/public/assets/rotate-ccw-CcU_XD8B.js.gz +0 -0
  230. package/payload/server/public/assets/{routines-NIMElUzV.js → routines-CA7D0myr.js} +1 -1
  231. package/payload/server/public/assets/routines-CA7D0myr.js.br +0 -0
  232. package/payload/server/public/assets/routines-CA7D0myr.js.gz +0 -0
  233. package/payload/server/public/assets/{skills-N0ZlHosx.js → skills-CfVPPSxO.js} +1 -1
  234. package/payload/server/public/assets/skills-CfVPPSxO.js.br +0 -0
  235. package/payload/server/public/assets/skills-CfVPPSxO.js.gz +0 -0
  236. package/payload/server/public/assets/tasks-DgieyZqz.js +1 -0
  237. package/payload/server/public/assets/tasks-DgieyZqz.js.br +0 -0
  238. package/payload/server/public/assets/tasks-DgieyZqz.js.gz +0 -0
  239. package/payload/server/public/assets/{time-entry-format-jQxlRPoI.js → time-entry-format-IFqinQJj.js} +1 -1
  240. package/payload/server/public/assets/time-entry-format-IFqinQJj.js.br +0 -0
  241. package/payload/server/public/assets/time-entry-format-IFqinQJj.js.gz +0 -0
  242. package/payload/server/public/assets/{triangle-alert-wUS4rpzv.js → triangle-alert-C5vH__sq.js} +1 -1
  243. package/payload/server/public/assets/triangle-alert-C5vH__sq.js.br +0 -0
  244. package/payload/server/public/assets/triangle-alert-C5vH__sq.js.gz +0 -0
  245. package/payload/server/public/assets/{useCopyFeedback-BRix_wIl.js → useCopyFeedback-BeL2gPl6.js} +1 -1
  246. package/payload/server/public/assets/useCopyFeedback-BeL2gPl6.js.br +0 -0
  247. package/payload/server/public/assets/useCopyFeedback-BeL2gPl6.js.gz +0 -0
  248. package/payload/server/public/assets/useSubAccountSwitcher-CH9qm2Ad.js +14 -0
  249. package/payload/server/public/assets/useSubAccountSwitcher-CH9qm2Ad.js.br +0 -0
  250. package/payload/server/public/assets/useSubAccountSwitcher-CH9qm2Ad.js.gz +0 -0
  251. package/payload/server/public/assets/useSubAccountSwitcher-DLqUQzOl.css +1 -0
  252. package/payload/server/public/assets/useSubAccountSwitcher-DLqUQzOl.css.br +0 -0
  253. package/payload/server/public/assets/useSubAccountSwitcher-DLqUQzOl.css.gz +0 -0
  254. package/payload/server/public/assets/useVoiceRecorder-Cpw3aOSt.js +2 -0
  255. package/payload/server/public/assets/useVoiceRecorder-Cpw3aOSt.js.br +0 -0
  256. package/payload/server/public/assets/useVoiceRecorder-Cpw3aOSt.js.gz +0 -0
  257. package/payload/server/public/assets/{wrench-sDivAjhf.js → wrench-BBEup3hd.js} +1 -1
  258. package/payload/server/public/assets/wrench-BBEup3hd.js.br +0 -0
  259. package/payload/server/public/assets/wrench-BBEup3hd.js.gz +0 -0
  260. package/payload/server/public/browser.html +4 -4
  261. package/payload/server/public/calendar.html +7 -7
  262. package/payload/server/public/chat.html +13 -13
  263. package/payload/server/public/data.html +11 -11
  264. package/payload/server/public/graph.html +9 -9
  265. package/payload/server/public/index.html +14 -14
  266. package/payload/server/public/operator.html +14 -14
  267. package/payload/server/public/public.html +13 -13
  268. package/payload/server/public/routines.html +6 -6
  269. package/payload/server/public/skills.html +5 -5
  270. package/payload/server/public/tasks.html +6 -6
  271. package/payload/server/server.js +8176 -11914
  272. package/payload/server/public/assets/AdminLoginScreens-CDpNkwkf.js +0 -1
  273. package/payload/server/public/assets/AdminLoginScreens-CDpNkwkf.js.br +0 -0
  274. package/payload/server/public/assets/AdminLoginScreens-CDpNkwkf.js.gz +0 -0
  275. package/payload/server/public/assets/AdminShell-TSa8KgID.js +0 -2
  276. package/payload/server/public/assets/AdminShell-TSa8KgID.js.br +0 -0
  277. package/payload/server/public/assets/AdminShell-TSa8KgID.js.gz +0 -0
  278. package/payload/server/public/assets/activity-BE9gv5xL.js.br +0 -0
  279. package/payload/server/public/assets/activity-BE9gv5xL.js.gz +0 -0
  280. package/payload/server/public/assets/admin-Cb0BZriv.js +0 -1
  281. package/payload/server/public/assets/admin-Cb0BZriv.js.br +0 -0
  282. package/payload/server/public/assets/admin-Cb0BZriv.js.gz +0 -0
  283. package/payload/server/public/assets/agents-B-x0Ai2v.js.br +0 -0
  284. package/payload/server/public/assets/agents-B-x0Ai2v.js.gz +0 -0
  285. package/payload/server/public/assets/browser-ZEQUkPfs.js.br +0 -0
  286. package/payload/server/public/assets/browser-ZEQUkPfs.js.gz +0 -0
  287. package/payload/server/public/assets/calendar-DsRxrW0G.js +0 -1
  288. package/payload/server/public/assets/calendar-DsRxrW0G.js.br +0 -0
  289. package/payload/server/public/assets/calendar-DsRxrW0G.js.gz +0 -0
  290. package/payload/server/public/assets/chat-BS9B9lDo.js +0 -1
  291. package/payload/server/public/assets/chat-BS9B9lDo.js.br +0 -1
  292. package/payload/server/public/assets/chat-BS9B9lDo.js.gz +0 -0
  293. package/payload/server/public/assets/chevron-left-DLtGuH6K.js +0 -1
  294. package/payload/server/public/assets/chevron-right-CY_trg5L.js +0 -1
  295. package/payload/server/public/assets/chevron-right-CY_trg5L.js.br +0 -0
  296. package/payload/server/public/assets/clock-oM5driJm.js +0 -1
  297. package/payload/server/public/assets/clock-oM5driJm.js.br +0 -0
  298. package/payload/server/public/assets/clock-oM5driJm.js.gz +0 -0
  299. package/payload/server/public/assets/data-Cio6PO0F.js +0 -1
  300. package/payload/server/public/assets/data-Cio6PO0F.js.br +0 -2
  301. package/payload/server/public/assets/data-Cio6PO0F.js.gz +0 -0
  302. package/payload/server/public/assets/file-text-BTV6EdBv.js.br +0 -0
  303. package/payload/server/public/assets/file-text-BTV6EdBv.js.gz +0 -0
  304. package/payload/server/public/assets/graph-hD_gPbbL.js.br +0 -0
  305. package/payload/server/public/assets/graph-hD_gPbbL.js.gz +0 -0
  306. package/payload/server/public/assets/graph-labels-CY1T19PY.js.br +0 -0
  307. package/payload/server/public/assets/graph-labels-CY1T19PY.js.gz +0 -0
  308. package/payload/server/public/assets/maximize-2-BFV9VKHO.js.br +0 -0
  309. package/payload/server/public/assets/maximize-2-BFV9VKHO.js.gz +0 -0
  310. package/payload/server/public/assets/operator-Dxze8CwV.js +0 -1
  311. package/payload/server/public/assets/operator-Dxze8CwV.js.br +0 -0
  312. package/payload/server/public/assets/operator-Dxze8CwV.js.gz +0 -0
  313. package/payload/server/public/assets/page-Bi86QF6_.js +0 -32
  314. package/payload/server/public/assets/page-Bi86QF6_.js.br +0 -0
  315. package/payload/server/public/assets/page-Bi86QF6_.js.gz +0 -0
  316. package/payload/server/public/assets/page-BnXly9aN.js +0 -1
  317. package/payload/server/public/assets/page-BnXly9aN.js.br +0 -0
  318. package/payload/server/public/assets/page-BnXly9aN.js.gz +0 -0
  319. package/payload/server/public/assets/public-D6Tnj5LU.js.br +0 -0
  320. package/payload/server/public/assets/public-D6Tnj5LU.js.gz +0 -0
  321. package/payload/server/public/assets/rotate-ccw-C-RfQUza.js.br +0 -0
  322. package/payload/server/public/assets/rotate-ccw-C-RfQUza.js.gz +0 -0
  323. package/payload/server/public/assets/routines-NIMElUzV.js.br +0 -0
  324. package/payload/server/public/assets/routines-NIMElUzV.js.gz +0 -0
  325. package/payload/server/public/assets/skills-N0ZlHosx.js.br +0 -0
  326. package/payload/server/public/assets/skills-N0ZlHosx.js.gz +0 -0
  327. package/payload/server/public/assets/tasks-BsB_0duv.js +0 -1
  328. package/payload/server/public/assets/tasks-BsB_0duv.js.br +0 -0
  329. package/payload/server/public/assets/tasks-BsB_0duv.js.gz +0 -0
  330. package/payload/server/public/assets/time-entry-format-jQxlRPoI.js.br +0 -0
  331. package/payload/server/public/assets/time-entry-format-jQxlRPoI.js.gz +0 -0
  332. package/payload/server/public/assets/triangle-alert-wUS4rpzv.js.br +0 -0
  333. package/payload/server/public/assets/triangle-alert-wUS4rpzv.js.gz +0 -0
  334. package/payload/server/public/assets/useCopyFeedback-BRix_wIl.js.br +0 -0
  335. package/payload/server/public/assets/useCopyFeedback-BRix_wIl.js.gz +0 -0
  336. package/payload/server/public/assets/useSubAccountSwitcher-DWMWxvYo.js +0 -14
  337. package/payload/server/public/assets/useSubAccountSwitcher-DWMWxvYo.js.br +0 -0
  338. package/payload/server/public/assets/useSubAccountSwitcher-DWMWxvYo.js.gz +0 -0
  339. package/payload/server/public/assets/useSubAccountSwitcher-UQQDcj0B.css +0 -1
  340. package/payload/server/public/assets/useSubAccountSwitcher-UQQDcj0B.css.br +0 -0
  341. package/payload/server/public/assets/useSubAccountSwitcher-UQQDcj0B.css.gz +0 -0
  342. package/payload/server/public/assets/useVoiceRecorder-1TAfCP2k.js +0 -2
  343. package/payload/server/public/assets/useVoiceRecorder-1TAfCP2k.js.br +0 -0
  344. package/payload/server/public/assets/useVoiceRecorder-1TAfCP2k.js.gz +0 -0
  345. package/payload/server/public/assets/wrench-sDivAjhf.js.br +0 -0
  346. package/payload/server/public/assets/wrench-sDivAjhf.js.gz +0 -0
@@ -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,18 @@ 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.520)
13
+
14
+ - The admin now lands on the Dashboard, with a Public pages card linking the booking site and file-drop portal.
15
+ - The scheduling read surface now returns meetings alongside events, so a note-only meeting update explains why instead of saying not found.
16
+ - New email tools: a signature setter to populate the deterministic signature loader, and a drafts list to confirm a draft is really there.
17
+
18
+ ## 2026-07-28 (0.1.519)
19
+
20
+ - 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).
21
+ - 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.
22
+ - WhatsApp group messages in the reader show sender colours and avatars.
23
+
12
24
  ## 2026-07-28 (0.1.518)
13
25
 
14
26
  - WhatsApp sender numbers are now shown in full international form instead of a bare string of digits.
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env node
2
+ // portal-assemble.mjs - fill a data portal's wrangler.toml from the shared
3
+ // house store identity and the account's own config.
4
+ //
5
+ // This exists because hand-filling those values is what broke a portal (Task
6
+ // 2099). The skill used to say "fill __D1_DATABASE_NAME__, __D1_DATABASE_ID__
7
+ // and __R2_BUCKET_NAME__, leave no placeholder", and two sessions resolving the
8
+ // same placeholder independently chose different names: a bucket was created as
9
+ // <acct>-portal while the config named <acct>-data-portal, and cross-account
10
+ // list isolation meant neither session could see the other's resource. The
11
+ // platform rule is to take the model out of a decision that has one right
12
+ // answer, so the values now come from one file and are substituted here.
13
+ //
14
+ // Every refusal below is fail-closed and names what is wrong. A portal that
15
+ // deploys with a blank binding is worse than one that does not deploy: it
16
+ // serves, and every upload lands nowhere.
17
+ //
18
+ // Pure core (assembleTree) is separated from the CLI so the refusals are
19
+ // testable without a filesystem.
20
+
21
+ import { readFileSync, writeFileSync } from 'node:fs'
22
+ import { basename, join, resolve } from 'node:path'
23
+
24
+ /** The three keys the shared store is identified by, in cloudflare-house.env. */
25
+ export const SHARED_KEYS = ['DATA_PORTAL_DB_NAME', 'DATA_PORTAL_DB_ID', 'DATA_PORTAL_BUCKET']
26
+
27
+ /**
28
+ * Parse KEY=value lines without polluting process.env. Same discipline as
29
+ * readHouseCredential in lib/storage-broker, and the same file is being read.
30
+ * @param {string} raw
31
+ * @returns {Record<string,string>}
32
+ */
33
+ export function parseEnvFile(raw) {
34
+ /** @type {Record<string,string>} */
35
+ const env = {}
36
+ for (const line of raw.split('\n')) {
37
+ const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)=(.*)$/)
38
+ if (m) env[m[1]] = m[2].trim()
39
+ }
40
+ return env
41
+ }
42
+
43
+ /**
44
+ * Substitute every placeholder in the template wrangler.toml.
45
+ *
46
+ * @param {{
47
+ * toml: string,
48
+ * shared: Record<string,string>,
49
+ * cfg: { accountId?: string, projectName?: string, storageModel?: string },
50
+ * installOrigin: string,
51
+ * expectedAccountId?: string,
52
+ * }} opts
53
+ * @returns {string} the assembled wrangler.toml
54
+ */
55
+ export function assembleTree({ toml, shared, cfg, installOrigin, expectedAccountId }) {
56
+ // The storage model is checked FIRST, because a dedicated account is not a
57
+ // missing-value problem to report but a portal this template cannot serve at
58
+ // all: its database has no accountId column, and every query this template
59
+ // emits binds one. Absent means dedicated, so a pre-2099 config file that
60
+ // never heard of the field is refused rather than silently treated as shared.
61
+ const model = cfg?.storageModel ?? 'dedicated'
62
+ if (model !== 'shared') {
63
+ throw new Error(
64
+ `portal-assemble: account ${cfg?.accountId ?? '(unnamed)'} is on the "${model}" storage ` +
65
+ `model. This template targets the shared house store and binds accountId in every query, ` +
66
+ `which a dedicated database does not have. Pre-2099 portals stay on their own template; ` +
67
+ `migrating one is deliberate work, not a redeploy.`,
68
+ )
69
+ }
70
+
71
+ for (const key of SHARED_KEYS) {
72
+ if (!shared?.[key]) {
73
+ throw new Error(
74
+ `portal-assemble: ${key} is missing or empty in the house config. The shared store must ` +
75
+ `exist and be recorded before any portal can bind it; assembling without it would ` +
76
+ `deploy a portal whose binding points at nothing.`,
77
+ )
78
+ }
79
+ }
80
+ if (!cfg?.accountId) {
81
+ throw new Error('portal-assemble: accountId is missing from data-portal.json')
82
+ }
83
+ // PORTAL_ACCOUNT_ID becomes the tenant scope for every query and every object
84
+ // key the deployed worker issues, and it is baked in permanently at this
85
+ // point. The house-side loops do NOT read it: they scope by the account
86
+ // DIRECTORY name. So the two must be the same string, or the worker writes
87
+ // rows and objects under one tenant while the pull, the index push and the
88
+ // audit look under another. Nothing downstream would report that: the reads
89
+ // simply return nothing and the client's files never arrive.
90
+ if (expectedAccountId && cfg.accountId !== expectedAccountId) {
91
+ throw new Error(
92
+ `portal-assemble: data-portal.json says accountId "${cfg.accountId}" but the account ` +
93
+ `directory is "${expectedAccountId}". The house-side loops scope by the directory, so ` +
94
+ `baking the other value would strand every upload. Fix the config, not this check.`,
95
+ )
96
+ }
97
+ // The same charset the deployed worker's authorizeKey enforces on both key
98
+ // segments. Baking a value it will reject means every upload 403s after
99
+ // deploy rather than here.
100
+ if (!/^[a-z0-9-]+$/.test(cfg.accountId)) {
101
+ throw new Error(
102
+ `portal-assemble: accountId "${cfg.accountId}" is outside [a-z0-9-], which the portal's own ` +
103
+ `key gate rejects, so every upload would be denied after deploy.`,
104
+ )
105
+ }
106
+ if (!cfg?.projectName) {
107
+ throw new Error('portal-assemble: projectName is missing from data-portal.json')
108
+ }
109
+ if (!installOrigin) {
110
+ throw new Error('portal-assemble: install origin is required (the public host, not the admin host)')
111
+ }
112
+
113
+ const out = toml
114
+ .replaceAll('__PROJECT_NAME__', cfg.projectName)
115
+ .replaceAll('__D1_DATABASE_NAME__', shared.DATA_PORTAL_DB_NAME)
116
+ .replaceAll('__D1_DATABASE_ID__', shared.DATA_PORTAL_DB_ID)
117
+ .replaceAll('__R2_BUCKET_NAME__', shared.DATA_PORTAL_BUCKET)
118
+ .replaceAll('__PORTAL_ACCOUNT_ID__', cfg.accountId)
119
+ .replaceAll('__PORTAL_INSTALL_ORIGIN__', installOrigin)
120
+
121
+ // The post-condition, verified rather than assumed. A placeholder that
122
+ // survives substitution is a value nobody supplied, and wrangler would
123
+ // happily deploy the literal string.
124
+ const left = out.match(/__[A-Z0-9_]+__/g)
125
+ if (left) {
126
+ throw new Error(
127
+ `portal-assemble: placeholder(s) left unfilled: ${[...new Set(left)].join(', ')}`,
128
+ )
129
+ }
130
+ return out
131
+ }
132
+
133
+ /** @param {string} msg */
134
+ function die(msg) {
135
+ console.error(`[portal-assemble] error: ${msg}`)
136
+ process.exit(1)
137
+ }
138
+
139
+ /** @param {string} flag */
140
+ function arg(flag) {
141
+ const i = process.argv.indexOf(flag)
142
+ return i === -1 ? undefined : process.argv[i + 1]
143
+ }
144
+
145
+ // Run only as a CLI, so the pure core above stays importable by the tests.
146
+ if (process.argv[1] && process.argv[1].endsWith('portal-assemble.mjs')) {
147
+ const accountDir = arg('--account-dir')
148
+ const platformRoot = arg('--platform-root')
149
+ const installOrigin = arg('--install-origin')
150
+ const templateToml = arg('--template')
151
+ if (!accountDir) die('--account-dir <dir> is required')
152
+ if (!platformRoot) die('--platform-root <dir> is required')
153
+ if (!installOrigin) die('--install-origin <https://host> is required')
154
+ if (!templateToml) die('--template <path to template wrangler.toml> is required')
155
+
156
+ try {
157
+ const shared = parseEnvFile(
158
+ readFileSync(join(/** @type {string} */ (platformRoot), 'config', 'cloudflare-house.env'), 'utf8'),
159
+ )
160
+ const cfg = JSON.parse(
161
+ readFileSync(join(/** @type {string} */ (accountDir), 'data-portal.json'), 'utf8'),
162
+ )
163
+ const toml = readFileSync(/** @type {string} */ (templateToml), 'utf8')
164
+ const out = assembleTree({
165
+ toml,
166
+ shared,
167
+ cfg,
168
+ installOrigin: /** @type {string} */ (installOrigin),
169
+ // The directory is the id every house-side loop scopes by, so it is the
170
+ // one the baked-in value has to match.
171
+ expectedAccountId: basename(resolve(/** @type {string} */ (accountDir))),
172
+ })
173
+ const dest = join(
174
+ /** @type {string} */ (accountDir),
175
+ 'sites',
176
+ cfg.projectName,
177
+ 'wrangler.toml',
178
+ )
179
+ writeFileSync(dest, out)
180
+ console.log(
181
+ `[portal-assemble] op=assemble account=${cfg.accountId} project=${cfg.projectName} ` +
182
+ `db=${shared.DATA_PORTAL_DB_NAME} bucket=${shared.DATA_PORTAL_BUCKET} result=ok dest=${dest}`,
183
+ )
184
+ } catch (err) {
185
+ die(/** @type {Error} */ (err).message)
186
+ }
187
+ }
@@ -71,6 +71,23 @@ if (!/^[A-Za-z0-9-]+$/.test(/** @type {string} */ (accountId))) {
71
71
  die('--account must match ^[A-Za-z0-9-]+$ — an account id, not a path')
72
72
  }
73
73
 
74
+ // Which people table this row is going into (Task 2099), because the two have
75
+ // different keys and SQLite requires an ON CONFLICT target to match a real
76
+ // unique index.
77
+ //
78
+ // shared PRIMARY KEY (accountId, ownerId) the one house store
79
+ // dedicated ownerId TEXT PRIMARY KEY a pre-2099 portal's own database
80
+ //
81
+ // Getting this wrong cannot corrupt anything: the conflict target simply does
82
+ // not match an index and SQLite refuses the statement outright with "ON CONFLICT
83
+ // clause does not match any PRIMARY KEY or UNIQUE constraint". It fails at the
84
+ // database, before any row moves, which is why a default is safe here. It
85
+ // defaults to the go-forward model; a grandfathered portal must say so.
86
+ const storageModel = arg('--storage-model') ?? 'shared'
87
+ if (storageModel !== 'shared' && storageModel !== 'dedicated') {
88
+ die('--storage-model must be "shared" or "dedicated" (see the account\'s data-portal.json)')
89
+ }
90
+
74
91
  // Optional per-person folder allowlist: comma-separated top-level names that
75
92
  // narrow what this person sees below the account's exposed set. Absent means
76
93
  // the full set. Each name is validated to the dir-segment charset, so a grant
@@ -116,12 +133,36 @@ const createdAt = new Date().toISOString()
116
133
  // `createdAt` is deliberately absent from the DO UPDATE: it records when the
117
134
  // person was enrolled, and rotating a passcode is not creating a person.
118
135
  //
119
- // `accountId` IS in the DO UPDATE: re-enrolling someone against a different
120
- // sub-account has to move them, or they keep reading folders they no longer
121
- // belong to. `folders` IS too, for the same reason: re-enrolment replaces the
122
- // grant, so a narrower or wider set on re-run takes effect rather than sticking.
136
+ // On the shared store the conflict target is (accountId, ownerId), not ownerId
137
+ // alone (Task 2099). That table is shared by every portal, so two clients may
138
+ // each enrol a person of the same name: conflicting on ownerId alone would treat
139
+ // the second client's enrolment as a rotation of the first client's person and
140
+ // overwrite their passcode, their grant, and the ownership of their files.
141
+ //
142
+ // On a grandfathered database the key really is ownerId alone, and naming the
143
+ // pair there does not degrade gracefully: SQLite refuses the whole statement,
144
+ // which would silently take passcode rotation away from those portals. Rotation
145
+ // is the one remedy for a leaked passcode, so it must keep working on every
146
+ // portal, not just the new ones.
147
+ //
148
+ // On the shared path `accountId` is NOT in the DO UPDATE: it is half the key
149
+ // that decides which row was matched, so it cannot also be a field the match
150
+ // rewrites. A consequence worth stating plainly, because it used to be possible:
151
+ // re-running enrolment with a different --account no longer MOVES a person, it
152
+ // enrols a separate person in that other portal. `--account` is the portal's own
153
+ // account, and a person always reads that portal's folders (Task 2104).
154
+ //
155
+ // The dedicated path keeps `accountId` in the DO UPDATE exactly as before, so a
156
+ // grandfathered portal's behaviour is unchanged in every respect.
157
+ //
158
+ // `folders` IS in the DO UPDATE on both: re-enrolment replaces the grant, so a
159
+ // narrower or wider set on re-run takes effect rather than sticking.
160
+ const conflict =
161
+ storageModel === 'shared'
162
+ ? 'ON CONFLICT(accountId, ownerId) DO UPDATE SET name=excluded.name, salt=excluded.salt, hash=excluded.hash, folders=excluded.folders'
163
+ : 'ON CONFLICT(ownerId) DO UPDATE SET name=excluded.name, salt=excluded.salt, hash=excluded.hash, accountId=excluded.accountId, folders=excluded.folders'
123
164
  console.log(
124
- `INSERT INTO people (ownerId, name, salt, hash, createdAt, accountId, folders) VALUES ('${ownerId}', '${sqlName}', '${saltHex}', '${hash}', '${createdAt}', '${accountId}', '${folders}') ON CONFLICT(ownerId) DO UPDATE SET name=excluded.name, salt=excluded.salt, hash=excluded.hash, accountId=excluded.accountId, folders=excluded.folders;`,
165
+ `INSERT INTO people (ownerId, name, salt, hash, createdAt, accountId, folders) VALUES ('${ownerId}', '${sqlName}', '${saltHex}', '${hash}', '${createdAt}', '${accountId}', '${folders}') ${conflict};`,
125
166
  )
126
167
 
127
168
  // stderr: everything for the human. Never stdout — see the note at the top.
@@ -12,11 +12,16 @@ const SALT = '000102030405060708090a0b0c0d0e0f'
12
12
  const RIGHT = generatePasscode()
13
13
  const WRONG = generatePasscode()
14
14
  const IP = '203.0.113.7'
15
+ // This portal's own account, baked into its wrangler vars at assemble time. One
16
+ // shared store backs every portal (Task 2099), so this value is what separates
17
+ // these rows from another client's and every query below binds it.
18
+ const ACCOUNT = 'acct-a'
15
19
 
16
20
  async function envWith(people: Array<{ ownerId: string; passcode: string }>, attemptCount = 0) {
17
21
  const rows = await Promise.all(
18
22
  people.map(async (p) => ({
19
23
  ownerId: p.ownerId,
24
+ accountId: ACCOUNT,
20
25
  salt: SALT,
21
26
  hash: await hashPasscode(p.passcode, SALT),
22
27
  })),
@@ -45,7 +50,11 @@ async function envWith(people: Array<{ ownerId: string; passcode: string }>, att
45
50
  async first<T>() {
46
51
  if (/FROM auth_attempts/i.test(query)) return { count: attemptCount + 1 } as T
47
52
  if (/FROM people/i.test(query)) {
48
- return (rows.find((r) => r.ownerId === bound[0]) ?? null) as T | null
53
+ // Keyed on the pair, because the people table is shared: bound[0]
54
+ // is the ownerId, bound[1] this portal's account. An ownerId-only
55
+ // fake would let a same-named person in another tenant answer.
56
+ return (rows.find((r) => r.ownerId === bound[0] && r.accountId === bound[1]) ??
57
+ null) as T | null
49
58
  }
50
59
  return null as T | null
51
60
  },
@@ -54,6 +63,7 @@ async function envWith(people: Array<{ ownerId: string; passcode: string }>, att
54
63
  },
55
64
  },
56
65
  BUCKET: {} as never,
66
+ PORTAL_ACCOUNT_ID: ACCOUNT,
57
67
  } as unknown as PortalEnv
58
68
  return { env, sessions, cleared, scopes }
59
69
  }
@@ -174,12 +184,14 @@ describe('processAuth', () => {
174
184
  expect(hashesForUnknown).toBe(hashesForKnown)
175
185
  })
176
186
 
177
- it('scopes the rate limiter on owner AND ip, so one caller cannot lock out another', async () => {
187
+ it('scopes the rate limiter on account, owner AND ip, so one caller cannot lock out another', async () => {
178
188
  // Keyed on the owner alone the counter is a weapon: six unauthenticated
179
- // requests naming a person lock that person out of their own portal.
189
+ // requests naming a person lock that person out of their own portal. The
190
+ // account leads the key because auth_attempts is shared by every portal, so
191
+ // without it two clients who both enrol an "alice" share one lockout.
180
192
  const { env, scopes } = await envWith([{ ownerId: 'alice', passcode: RIGHT }])
181
193
  await processAuth({ ownerId: 'alice', passcode: RIGHT }, env, () => {}, () => 's', 1_000_000, IP)
182
- expect(scopes[0]).toBe(`alice|${IP}`)
194
+ expect(scopes[0]).toBe(`${ACCOUNT}|alice|${IP}`)
183
195
  expect(String(scopes[0])).toContain(IP)
184
196
  })
185
197
 
@@ -187,7 +199,7 @@ describe('processAuth', () => {
187
199
  const { env, cleared } = await envWith([{ ownerId: 'alice', passcode: RIGHT }])
188
200
  await processAuth({ ownerId: 'alice', passcode: RIGHT }, env, () => {}, () => 's', 1_000_000, IP)
189
201
  expect(cleared.length).toBe(1)
190
- expect(cleared[0][0]).toBe(`alice|${IP}`)
202
+ expect(cleared[0][0]).toBe(`${ACCOUNT}|alice|${IP}`)
191
203
  })
192
204
 
193
205
  it('does not clear the counter on a failed sign-in', async () => {
@@ -203,8 +215,8 @@ describe('processAuth', () => {
203
215
  const { env, sessions } = await envWith([{ ownerId: 'alice', passcode: RIGHT }])
204
216
  await processAuth({ ownerId: 'alice', passcode: RIGHT }, env, () => {}, () => 's', 1_000_000, IP)
205
217
  const expected = (await hashPasscode(RIGHT, SALT)).slice(0, 16)
206
- // bind order: sessionId, ownerId, pcCheck, expiresAt, createdAt
207
- expect(sessions[0][2]).toBe(expected)
218
+ // bind order: sessionId, ownerId, accountId, pcCheck, expiresAt, createdAt
219
+ expect(sessions[0][3]).toBe(expected)
208
220
  })
209
221
 
210
222
  it('quotes the owner in log lines, so a crafted ownerId cannot forge one', async () => {