@rubytech/create-sitedesk-code 0.1.519 → 0.1.521

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 (317) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/docs/superpowers/plans/2026-07-28-task-2097-email-signature-set.md +65 -0
  3. package/payload/platform/docs/superpowers/specs/2026-07-28-task-2097-email-signature-set-design.md +155 -0
  4. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +4 -2
  5. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +12 -0
  6. package/payload/platform/plugins/cloudflare/bin/portal-assemble.mjs +187 -0
  7. package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +46 -5
  8. package/payload/platform/plugins/cloudflare/mcp/__tests__/auth-route.test.ts +19 -7
  9. package/payload/platform/plugins/cloudflare/mcp/__tests__/authorize.test.ts +154 -38
  10. package/payload/platform/plugins/cloudflare/mcp/__tests__/file-routes.test.ts +100 -29
  11. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-assemble.test.ts +143 -0
  12. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-delete-withdraw.test.ts +12 -5
  13. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts +15 -3
  14. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +105 -11
  15. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +15 -2
  16. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-moved-download.test.ts +9 -5
  17. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-schema-shape.test.ts +47 -5
  18. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-session-grant.test.ts +26 -2
  19. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-target.test.ts +12 -4
  20. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-upload-multipart.test.ts +23 -15
  21. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-upload-target.test.ts +23 -15
  22. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-uploads-listing.test.ts +8 -0
  23. package/payload/platform/plugins/cloudflare/mcp/__tests__/skill-contract.test.ts +23 -6
  24. package/payload/platform/plugins/cloudflare/mcp/__tests__/upload-route.test.ts +36 -20
  25. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +85 -22
  26. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +47 -7
  27. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/authorize.ts +27 -18
  28. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/ratelimit.ts +10 -3
  29. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/session.ts +27 -11
  30. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/target.ts +18 -9
  31. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/types.ts +16 -0
  32. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/auth.ts +16 -4
  33. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/delete.ts +26 -11
  34. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +14 -10
  35. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +13 -9
  36. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/upload.ts +42 -22
  37. package/payload/platform/plugins/cloudflare/skills/data-portal/template/wrangler.toml +17 -4
  38. package/payload/platform/plugins/docs/references/admin-ui.md +1 -1
  39. package/payload/platform/plugins/email/.claude-plugin/plugin.json +1 -1
  40. package/payload/platform/plugins/email/PLUGIN.md +20 -1
  41. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-metadata-calendar.test.js +46 -1
  42. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-metadata-calendar.test.js.map +1 -1
  43. package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-list-tool.test.d.ts +2 -0
  44. package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-list-tool.test.d.ts.map +1 -0
  45. package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-list-tool.test.js +142 -0
  46. package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-list-tool.test.js.map +1 -0
  47. package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-list.test.d.ts +2 -0
  48. package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-list.test.d.ts.map +1 -0
  49. package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-list.test.js +153 -0
  50. package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-list.test.js.map +1 -0
  51. package/payload/platform/plugins/email/mcp/dist/__tests__/email-signature-set.test.d.ts +2 -0
  52. package/payload/platform/plugins/email/mcp/dist/__tests__/email-signature-set.test.d.ts.map +1 -0
  53. package/payload/platform/plugins/email/mcp/dist/__tests__/email-signature-set.test.js +203 -0
  54. package/payload/platform/plugins/email/mcp/dist/__tests__/email-signature-set.test.js.map +1 -0
  55. package/payload/platform/plugins/email/mcp/dist/index.js +69 -0
  56. package/payload/platform/plugins/email/mcp/dist/index.js.map +1 -1
  57. package/payload/platform/plugins/email/mcp/dist/lib/compose.d.ts.map +1 -1
  58. package/payload/platform/plugins/email/mcp/dist/lib/compose.js +5 -7
  59. package/payload/platform/plugins/email/mcp/dist/lib/compose.js.map +1 -1
  60. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts +56 -0
  61. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts.map +1 -1
  62. package/payload/platform/plugins/email/mcp/dist/lib/imap.js +111 -4
  63. package/payload/platform/plugins/email/mcp/dist/lib/imap.js.map +1 -1
  64. package/payload/platform/plugins/email/mcp/dist/lib/signature.d.ts +38 -0
  65. package/payload/platform/plugins/email/mcp/dist/lib/signature.d.ts.map +1 -1
  66. package/payload/platform/plugins/email/mcp/dist/lib/signature.js +67 -2
  67. package/payload/platform/plugins/email/mcp/dist/lib/signature.js.map +1 -1
  68. package/payload/platform/plugins/email/mcp/dist/tools/email-draft-list.d.ts +10 -0
  69. package/payload/platform/plugins/email/mcp/dist/tools/email-draft-list.d.ts.map +1 -0
  70. package/payload/platform/plugins/email/mcp/dist/tools/email-draft-list.js +73 -0
  71. package/payload/platform/plugins/email/mcp/dist/tools/email-draft-list.js.map +1 -0
  72. package/payload/platform/plugins/email/mcp/dist/tools/email-signature-set.d.ts +18 -0
  73. package/payload/platform/plugins/email/mcp/dist/tools/email-signature-set.d.ts.map +1 -0
  74. package/payload/platform/plugins/email/mcp/dist/tools/email-signature-set.js +38 -0
  75. package/payload/platform/plugins/email/mcp/dist/tools/email-signature-set.js.map +1 -0
  76. package/payload/platform/plugins/email/references/email-reference.md +15 -3
  77. package/payload/platform/plugins/email/skills/email-composition/SKILL.md +10 -0
  78. package/payload/platform/plugins/email/skills/email-signature/SKILL.md +61 -0
  79. package/payload/platform/plugins/scheduling/PLUGIN.md +8 -0
  80. package/payload/platform/plugins/scheduling/mcp/dist/index.js +26 -15
  81. package/payload/platform/plugins/scheduling/mcp/dist/index.js.map +1 -1
  82. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/schedule-audit.test.d.ts +2 -0
  83. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/schedule-audit.test.d.ts.map +1 -0
  84. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/schedule-audit.test.js +48 -0
  85. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/schedule-audit.test.js.map +1 -0
  86. package/payload/platform/plugins/scheduling/mcp/dist/lib/schedule-audit.d.ts +24 -0
  87. package/payload/platform/plugins/scheduling/mcp/dist/lib/schedule-audit.d.ts.map +1 -0
  88. package/payload/platform/plugins/scheduling/mcp/dist/lib/schedule-audit.js +33 -0
  89. package/payload/platform/plugins/scheduling/mcp/dist/lib/schedule-audit.js.map +1 -0
  90. package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js +22 -1
  91. package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js.map +1 -1
  92. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-cancel-meeting.test.d.ts +2 -0
  93. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-cancel-meeting.test.d.ts.map +1 -0
  94. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-cancel-meeting.test.js +30 -0
  95. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-cancel-meeting.test.js.map +1 -0
  96. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-get-meeting.test.d.ts +2 -0
  97. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-get-meeting.test.d.ts.map +1 -0
  98. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-get-meeting.test.js +43 -0
  99. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-get-meeting.test.js.map +1 -0
  100. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-list.test.d.ts +2 -0
  101. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-list.test.d.ts.map +1 -0
  102. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-list.test.js +87 -0
  103. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-list.test.js.map +1 -0
  104. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-update-meeting.test.d.ts +2 -0
  105. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-update-meeting.test.d.ts.map +1 -0
  106. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-update-meeting.test.js +48 -0
  107. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-update-meeting.test.js.map +1 -0
  108. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-cancel.d.ts.map +1 -1
  109. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-cancel.js +14 -1
  110. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-cancel.js.map +1 -1
  111. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-get.d.ts +4 -1
  112. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-get.d.ts.map +1 -1
  113. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-get.js +39 -2
  114. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-get.js.map +1 -1
  115. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-list.d.ts +4 -1
  116. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-list.d.ts.map +1 -1
  117. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-list.js +50 -1
  118. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-list.js.map +1 -1
  119. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-update.d.ts.map +1 -1
  120. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-update.js +43 -1
  121. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-update.js.map +1 -1
  122. package/payload/platform/plugins/whatsapp/references/channels-whatsapp.md +3 -1
  123. package/payload/platform/scripts/check-architecture-skill-no-drift.mjs +20 -6
  124. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
  125. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +2 -0
  126. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
  127. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +17 -5
  128. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts.map +1 -1
  129. package/payload/platform/services/whatsapp-channel/dist/notification.js +30 -6
  130. package/payload/platform/services/whatsapp-channel/dist/notification.js.map +1 -1
  131. package/payload/platform/services/whatsapp-channel/dist/server.d.ts.map +1 -1
  132. package/payload/platform/services/whatsapp-channel/dist/server.js +53 -21
  133. package/payload/platform/services/whatsapp-channel/dist/server.js.map +1 -1
  134. package/payload/platform/services/whatsapp-channel/dist/turn-follow.d.ts +16 -0
  135. package/payload/platform/services/whatsapp-channel/dist/turn-follow.d.ts.map +1 -1
  136. package/payload/platform/services/whatsapp-channel/dist/turn-follow.js +41 -2
  137. package/payload/platform/services/whatsapp-channel/dist/turn-follow.js.map +1 -1
  138. package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -1
  139. package/payload/server/{chunk-TIIWMF5G.js → chunk-AZLT4LVO.js} +40 -7
  140. package/payload/server/{manager-B6OFOXSW.js → manager-CQBSUJXR.js} +1 -1
  141. package/payload/server/public/activity.html +5 -5
  142. package/payload/server/public/agents.html +4 -4
  143. package/payload/server/public/assets/AdminLoginScreens-Dar_MMJm.js +1 -0
  144. package/payload/server/public/assets/AdminLoginScreens-Dar_MMJm.js.br +0 -0
  145. package/payload/server/public/assets/AdminLoginScreens-Dar_MMJm.js.gz +0 -0
  146. package/payload/server/public/assets/AdminShell-CpaI2F_p.js +2 -0
  147. package/payload/server/public/assets/AdminShell-CpaI2F_p.js.br +0 -0
  148. package/payload/server/public/assets/AdminShell-CpaI2F_p.js.gz +0 -0
  149. package/payload/server/public/assets/{activity-DOsfKzT3.js → activity-B1rt4GIy.js} +1 -1
  150. package/payload/server/public/assets/activity-B1rt4GIy.js.br +0 -0
  151. package/payload/server/public/assets/activity-B1rt4GIy.js.gz +0 -0
  152. package/payload/server/public/assets/admin-DE3lLNje.js +1 -0
  153. package/payload/server/public/assets/admin-DE3lLNje.js.br +0 -0
  154. package/payload/server/public/assets/admin-DE3lLNje.js.gz +0 -0
  155. package/payload/server/public/assets/{agents-DqDnZTH_.js → agents-BXq8woVf.js} +1 -1
  156. package/payload/server/public/assets/agents-BXq8woVf.js.br +0 -0
  157. package/payload/server/public/assets/agents-BXq8woVf.js.gz +0 -0
  158. package/payload/server/public/assets/{browser-FL17Dfp0.js → browser-1QSCPl3X.js} +1 -1
  159. package/payload/server/public/assets/browser-1QSCPl3X.js.br +0 -0
  160. package/payload/server/public/assets/browser-1QSCPl3X.js.gz +0 -0
  161. package/payload/server/public/assets/{calendar-CLfLvsnV.js → calendar-D009qWVV.js} +1 -1
  162. package/payload/server/public/assets/calendar-D009qWVV.js.br +0 -0
  163. package/payload/server/public/assets/calendar-D009qWVV.js.gz +0 -0
  164. package/payload/server/public/assets/chat-CUg1fTqJ.js +1 -0
  165. package/payload/server/public/assets/chat-CUg1fTqJ.js.br +1 -0
  166. package/payload/server/public/assets/chat-CUg1fTqJ.js.gz +0 -0
  167. package/payload/server/public/assets/chevron-left-C2JEy7uU.js +1 -0
  168. package/payload/server/public/assets/chevron-left-C2JEy7uU.js.br +0 -0
  169. package/payload/server/public/assets/chevron-right-DWa047Ys.js +1 -0
  170. package/payload/server/public/assets/chevron-right-DWa047Ys.js.br +0 -0
  171. package/payload/server/public/assets/clock-C3OqfCnn.js +1 -0
  172. package/payload/server/public/assets/clock-C3OqfCnn.js.br +0 -0
  173. package/payload/server/public/assets/clock-C3OqfCnn.js.gz +0 -0
  174. package/payload/server/public/assets/data-Bu_yCScC.js +1 -0
  175. package/payload/server/public/assets/data-Bu_yCScC.js.br +0 -0
  176. package/payload/server/public/assets/data-Bu_yCScC.js.gz +0 -0
  177. package/payload/server/public/assets/{file-text-DNm0AzUz.js → file-text-DwGAfCsd.js} +1 -1
  178. package/payload/server/public/assets/file-text-DwGAfCsd.js.br +0 -0
  179. package/payload/server/public/assets/file-text-DwGAfCsd.js.gz +0 -0
  180. package/payload/server/public/assets/{graph-fAvmlVzh.js → graph-D-DGlfhW.js} +2 -2
  181. package/payload/server/public/assets/graph-D-DGlfhW.js.br +0 -0
  182. package/payload/server/public/assets/graph-D-DGlfhW.js.gz +0 -0
  183. package/payload/server/public/assets/{graph-labels-CwdkCxfs.js → graph-labels-BbX3fXzl.js} +1 -1
  184. package/payload/server/public/assets/graph-labels-BbX3fXzl.js.br +0 -0
  185. package/payload/server/public/assets/graph-labels-BbX3fXzl.js.gz +0 -0
  186. package/payload/server/public/assets/{maximize-2-B6tAPhC2.js → maximize-2-C_-KapxH.js} +1 -1
  187. package/payload/server/public/assets/maximize-2-C_-KapxH.js.br +0 -0
  188. package/payload/server/public/assets/maximize-2-C_-KapxH.js.gz +0 -0
  189. package/payload/server/public/assets/{operator-CTKvBVjk.js → operator-CFrZVcv6.js} +1 -1
  190. package/payload/server/public/assets/operator-CFrZVcv6.js.br +0 -0
  191. package/payload/server/public/assets/operator-CFrZVcv6.js.gz +0 -0
  192. package/payload/server/public/assets/{page-O5HkHQw4.js → page-CHt62f1_.js} +1 -1
  193. package/payload/server/public/assets/page-CHt62f1_.js.br +0 -0
  194. package/payload/server/public/assets/page-CHt62f1_.js.gz +0 -0
  195. package/payload/server/public/assets/page-E74MYB18.js +32 -0
  196. package/payload/server/public/assets/page-E74MYB18.js.br +0 -0
  197. package/payload/server/public/assets/page-E74MYB18.js.gz +0 -0
  198. package/payload/server/public/assets/{public-BF1rnVPa.js → public-CI6K1sNW.js} +1 -1
  199. package/payload/server/public/assets/public-CI6K1sNW.js.br +0 -0
  200. package/payload/server/public/assets/public-CI6K1sNW.js.gz +0 -0
  201. package/payload/server/public/assets/{rotate-ccw-B8TqfCy4.js → rotate-ccw-PWu_QGrD.js} +1 -1
  202. package/payload/server/public/assets/rotate-ccw-PWu_QGrD.js.br +0 -0
  203. package/payload/server/public/assets/rotate-ccw-PWu_QGrD.js.gz +0 -0
  204. package/payload/server/public/assets/routines-Cp3_XS7S.js +1 -0
  205. package/payload/server/public/assets/routines-Cp3_XS7S.js.br +0 -0
  206. package/payload/server/public/assets/routines-Cp3_XS7S.js.gz +0 -0
  207. package/payload/server/public/assets/skills-g8HvunPA.js +1 -0
  208. package/payload/server/public/assets/skills-g8HvunPA.js.br +0 -0
  209. package/payload/server/public/assets/skills-g8HvunPA.js.gz +0 -0
  210. package/payload/server/public/assets/{tasks-DePyOrRk.js → tasks-CMC_jDVm.js} +1 -1
  211. package/payload/server/public/assets/tasks-CMC_jDVm.js.br +0 -0
  212. package/payload/server/public/assets/tasks-CMC_jDVm.js.gz +0 -0
  213. package/payload/server/public/assets/{time-entry-format-D9gtnG01.js → time-entry-format-BXWuQ6Sw.js} +1 -1
  214. package/payload/server/public/assets/time-entry-format-BXWuQ6Sw.js.br +2 -0
  215. package/payload/server/public/assets/time-entry-format-BXWuQ6Sw.js.gz +0 -0
  216. package/payload/server/public/assets/{triangle-alert-DAtm2mLL.js → triangle-alert-BNshRC13.js} +1 -1
  217. package/payload/server/public/assets/triangle-alert-BNshRC13.js.br +0 -0
  218. package/payload/server/public/assets/triangle-alert-BNshRC13.js.gz +0 -0
  219. package/payload/server/public/assets/{useCopyFeedback-uMJT-p6i.js → useCopyFeedback-CwTZ9DqH.js} +1 -1
  220. package/payload/server/public/assets/useCopyFeedback-CwTZ9DqH.js.br +0 -0
  221. package/payload/server/public/assets/useCopyFeedback-CwTZ9DqH.js.gz +0 -0
  222. package/payload/server/public/assets/useSubAccountSwitcher-BuUYMDWl.js +14 -0
  223. package/payload/server/public/assets/useSubAccountSwitcher-BuUYMDWl.js.br +0 -0
  224. package/payload/server/public/assets/useSubAccountSwitcher-BuUYMDWl.js.gz +0 -0
  225. package/payload/server/public/assets/useSubAccountSwitcher-dzDnmWq9.css +1 -0
  226. package/payload/server/public/assets/useSubAccountSwitcher-dzDnmWq9.css.br +0 -0
  227. package/payload/server/public/assets/useSubAccountSwitcher-dzDnmWq9.css.gz +0 -0
  228. package/payload/server/public/assets/{useVoiceRecorder-BFrPC-s7.js → useVoiceRecorder-BGqV5R6u.js} +1 -1
  229. package/payload/server/public/assets/useVoiceRecorder-BGqV5R6u.js.br +0 -0
  230. package/payload/server/public/assets/useVoiceRecorder-BGqV5R6u.js.gz +0 -0
  231. package/payload/server/public/assets/{wrench-CYgaN7K9.js → wrench-DQ045Pyk.js} +1 -1
  232. package/payload/server/public/assets/wrench-DQ045Pyk.js.br +0 -0
  233. package/payload/server/public/assets/wrench-DQ045Pyk.js.gz +0 -0
  234. package/payload/server/public/browser.html +4 -4
  235. package/payload/server/public/calendar.html +7 -7
  236. package/payload/server/public/chat.html +13 -13
  237. package/payload/server/public/data.html +11 -11
  238. package/payload/server/public/graph.html +9 -9
  239. package/payload/server/public/index.html +14 -14
  240. package/payload/server/public/operator.html +14 -14
  241. package/payload/server/public/public.html +13 -13
  242. package/payload/server/public/routines.html +6 -6
  243. package/payload/server/public/skills.html +5 -5
  244. package/payload/server/public/tasks.html +6 -6
  245. package/payload/server/server.js +687 -400
  246. package/payload/server/public/assets/AdminLoginScreens-BZPbCk8g.js +0 -1
  247. package/payload/server/public/assets/AdminLoginScreens-BZPbCk8g.js.br +0 -0
  248. package/payload/server/public/assets/AdminLoginScreens-BZPbCk8g.js.gz +0 -0
  249. package/payload/server/public/assets/AdminShell-Bpo_wgxm.js +0 -2
  250. package/payload/server/public/assets/AdminShell-Bpo_wgxm.js.br +0 -0
  251. package/payload/server/public/assets/AdminShell-Bpo_wgxm.js.gz +0 -0
  252. package/payload/server/public/assets/activity-DOsfKzT3.js.br +0 -0
  253. package/payload/server/public/assets/activity-DOsfKzT3.js.gz +0 -0
  254. package/payload/server/public/assets/admin-COgD1z5a.js +0 -1
  255. package/payload/server/public/assets/admin-COgD1z5a.js.br +0 -0
  256. package/payload/server/public/assets/admin-COgD1z5a.js.gz +0 -0
  257. package/payload/server/public/assets/agents-DqDnZTH_.js.br +0 -0
  258. package/payload/server/public/assets/agents-DqDnZTH_.js.gz +0 -0
  259. package/payload/server/public/assets/browser-FL17Dfp0.js.br +0 -0
  260. package/payload/server/public/assets/browser-FL17Dfp0.js.gz +0 -0
  261. package/payload/server/public/assets/calendar-CLfLvsnV.js.br +0 -0
  262. package/payload/server/public/assets/calendar-CLfLvsnV.js.gz +0 -0
  263. package/payload/server/public/assets/chat-DYq4FxMG.js +0 -1
  264. package/payload/server/public/assets/chat-DYq4FxMG.js.br +0 -0
  265. package/payload/server/public/assets/chat-DYq4FxMG.js.gz +0 -0
  266. package/payload/server/public/assets/chevron-left-DWt5cdUo.js +0 -1
  267. package/payload/server/public/assets/chevron-right-B9suVrYy.js +0 -1
  268. package/payload/server/public/assets/chevron-right-B9suVrYy.js.br +0 -0
  269. package/payload/server/public/assets/clock-DW6qVXtv.js +0 -1
  270. package/payload/server/public/assets/clock-DW6qVXtv.js.br +0 -0
  271. package/payload/server/public/assets/clock-DW6qVXtv.js.gz +0 -0
  272. package/payload/server/public/assets/data-C8rbOgds.js +0 -1
  273. package/payload/server/public/assets/data-C8rbOgds.js.br +0 -0
  274. package/payload/server/public/assets/data-C8rbOgds.js.gz +0 -0
  275. package/payload/server/public/assets/file-text-DNm0AzUz.js.br +0 -0
  276. package/payload/server/public/assets/file-text-DNm0AzUz.js.gz +0 -0
  277. package/payload/server/public/assets/graph-fAvmlVzh.js.br +0 -0
  278. package/payload/server/public/assets/graph-fAvmlVzh.js.gz +0 -0
  279. package/payload/server/public/assets/graph-labels-CwdkCxfs.js.br +0 -0
  280. package/payload/server/public/assets/graph-labels-CwdkCxfs.js.gz +0 -0
  281. package/payload/server/public/assets/maximize-2-B6tAPhC2.js.br +0 -0
  282. package/payload/server/public/assets/maximize-2-B6tAPhC2.js.gz +0 -0
  283. package/payload/server/public/assets/operator-CTKvBVjk.js.br +0 -0
  284. package/payload/server/public/assets/operator-CTKvBVjk.js.gz +0 -0
  285. package/payload/server/public/assets/page-BWoNW3sa.js +0 -32
  286. package/payload/server/public/assets/page-BWoNW3sa.js.br +0 -0
  287. package/payload/server/public/assets/page-BWoNW3sa.js.gz +0 -0
  288. package/payload/server/public/assets/page-O5HkHQw4.js.br +0 -0
  289. package/payload/server/public/assets/page-O5HkHQw4.js.gz +0 -0
  290. package/payload/server/public/assets/public-BF1rnVPa.js.br +0 -0
  291. package/payload/server/public/assets/public-BF1rnVPa.js.gz +0 -0
  292. package/payload/server/public/assets/rotate-ccw-B8TqfCy4.js.br +0 -0
  293. package/payload/server/public/assets/rotate-ccw-B8TqfCy4.js.gz +0 -0
  294. package/payload/server/public/assets/routines-4R7BLZyH.js +0 -1
  295. package/payload/server/public/assets/routines-4R7BLZyH.js.br +0 -0
  296. package/payload/server/public/assets/routines-4R7BLZyH.js.gz +0 -0
  297. package/payload/server/public/assets/skills-CQ9Qs2tg.js +0 -1
  298. package/payload/server/public/assets/skills-CQ9Qs2tg.js.br +0 -0
  299. package/payload/server/public/assets/skills-CQ9Qs2tg.js.gz +0 -0
  300. package/payload/server/public/assets/tasks-DePyOrRk.js.br +0 -0
  301. package/payload/server/public/assets/tasks-DePyOrRk.js.gz +0 -0
  302. package/payload/server/public/assets/time-entry-format-D9gtnG01.js.br +0 -0
  303. package/payload/server/public/assets/time-entry-format-D9gtnG01.js.gz +0 -0
  304. package/payload/server/public/assets/triangle-alert-DAtm2mLL.js.br +0 -0
  305. package/payload/server/public/assets/triangle-alert-DAtm2mLL.js.gz +0 -0
  306. package/payload/server/public/assets/useCopyFeedback-uMJT-p6i.js.br +0 -0
  307. package/payload/server/public/assets/useCopyFeedback-uMJT-p6i.js.gz +0 -0
  308. package/payload/server/public/assets/useSubAccountSwitcher-BufCPDDH.js +0 -14
  309. package/payload/server/public/assets/useSubAccountSwitcher-BufCPDDH.js.br +0 -0
  310. package/payload/server/public/assets/useSubAccountSwitcher-BufCPDDH.js.gz +0 -0
  311. package/payload/server/public/assets/useSubAccountSwitcher-iCzsV96x.css +0 -1
  312. package/payload/server/public/assets/useSubAccountSwitcher-iCzsV96x.css.br +0 -0
  313. package/payload/server/public/assets/useSubAccountSwitcher-iCzsV96x.css.gz +0 -0
  314. package/payload/server/public/assets/useVoiceRecorder-BFrPC-s7.js.br +0 -0
  315. package/payload/server/public/assets/useVoiceRecorder-BFrPC-s7.js.gz +0 -0
  316. package/payload/server/public/assets/wrench-CYgaN7K9.js.br +0 -1
  317. package/payload/server/public/assets/wrench-CYgaN7K9.js.gz +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-sitedesk-code",
3
- "version": "0.1.519",
3
+ "version": "0.1.521",
4
4
  "description": "Install SiteDesk — automated back office for independent building contractors",
5
5
  "bin": {
6
6
  "create-sitedesk-code": "./dist/index.js"
@@ -0,0 +1,65 @@
1
+ # Task 2097 — `email-signature-set` implementation plan
2
+
3
+ Design: [`2026-07-28-task-2097-email-signature-set-design.md`](../specs/2026-07-28-task-2097-email-signature-set-design.md).
4
+
5
+ ## Registration surfaces — corrected against the tree
6
+
7
+ The task file names `ADMIN_CORE_TOOLS`. It does not exist: `grep -rn 'ADMIN_CORE_TOOLS' platform/` returns
8
+ nothing outside comments. The live surfaces, each confirmed by reading the file, are four:
9
+
10
+ | # | Surface | Evidence |
11
+ |---|---|---|
12
+ | 1 | `plugins/email/mcp/src/index.ts` | `eagerTool(server, "email-setup", …)` at line 124 |
13
+ | 2 | `plugins/email/PLUGIN.md` | frontmatter `description:`, `tools:` list, Capabilities, Skills table |
14
+ | 3 | `platform/templates/specialists/agents/personal-assistant.md` | the only template matching `grep -rl email-setup platform/templates/` |
15
+ | 4 | `services/claude-session-manager/src/canonical-tool-names.generated.ts` | header: generated from `PLUGIN.md` `tools:` blocks, `check-canonical-tool-names.mjs` fails the build on drift |
16
+
17
+ Surface 4 is not in the task file and would have broken the build. It is regenerated, never hand-edited:
18
+ `cd platform && npm run gen:canonical-tools`.
19
+
20
+ Three further files match `email-draft-send` and are deliberately **not** touched:
21
+ `plugins/admin/hooks/preference-consult-gate.sh`, `scripts/lib/provision-account-dir.sh`, and the
22
+ `plugins/admin/PLUGIN.md` prose describing them. All three are the outbound-send preference gate,
23
+ matching sends that carry attachments. `email-signature-set` sends nothing, so it is not a gate member.
24
+
25
+ ## Steps
26
+
27
+ 1. **`lib/signature.ts`** — add `SIGNATURE_BASENAME`, `signatureDirFor(accountId)`, and
28
+ `saveSignature(accountDir, parts)`. `loadSignature` / `applySignature` unchanged.
29
+ `signatureDirFor` is `accountDirFor(accountId) || process.env.ACCOUNT_DIR || undefined`, which is the
30
+ expression `compose.ts:112` already computes, with an empty-string env normalised to `undefined`
31
+ (both already load as no-signature, so behaviour is unchanged). No import cycle: `attachment-resolve.ts`
32
+ imports only `node:fs` and `node:path`.
33
+ 2. **`lib/compose.ts`** — replace the inline `sigDir` expression with `signatureDirFor(accountId)` and the
34
+ literal `"email-signature"` in `assetNote` with `SIGNATURE_BASENAME`. `accountDirFor` stays imported for
35
+ the attachment path at line 75.
36
+ 3. **`tools/email-signature-set.ts`** — refuse an all-absent call and an unresolvable directory before any
37
+ write, call `saveSignature`, log, return the resolved paths.
38
+ 4. **`index.ts`** — import and `eagerTool` register, placed after the `email-setup` block. Guard with
39
+ `refuseNoAccount`, wrap in try/catch like `email-setup`. The handler's local result variable is named
40
+ `message`, not `text`, so it cannot shadow the `text` parameter.
41
+ 5. **`PLUGIN.md`** — `tools:` entry (`riskClass: write_local`, both allowlists false, matching `email-setup`),
42
+ a Config clause in the frontmatter `description:`, a Capabilities bullet, a Skills-table row.
43
+ 6. **`personal-assistant.md`** — add `mcp__plugin_email_email__email-signature-set` after `email-setup`.
44
+ 7. **Regenerate** `canonical-tool-names.generated.ts`.
45
+ 8. **`skills/email-signature/SKILL.md`** — new skill.
46
+ 9. **`skills/email-composition/SKILL.md`** — one added rule, see below.
47
+ 10. **`__tests__/email-signature-set.test.ts`** — committed, not ephemeral: the plugin already carries 34
48
+ test files and the task file mandates a regression test.
49
+
50
+ ## Addition beyond the task file
51
+
52
+ The task specifies that the new `email-signature` skill carries the rule "never hand-build a signature
53
+ inside a draft body". That skill activates when the operator asks to set or change a signature. The
54
+ incident happened while **drafting**, when that skill is not loaded. `grep -in signature` over
55
+ `skills/email-composition/SKILL.md` returns zero hits, so the drafting surface currently says nothing
56
+ about signatures.
57
+
58
+ So the rule is also stated in one sentence in `email-composition`, which is the skill that is loaded while
59
+ drafting. Without it the doctrine lives only in a skill absent at the moment it is needed. Declared in Plan
60
+ Conformity as an addition.
61
+
62
+ ## Verification
63
+
64
+ `npm run build` and `npx vitest run` in `plugins/email/mcp`, then `npm run gen:canonical-tools` and the
65
+ `check-canonical-tool-names.mjs` gate in `platform/`. No browser surface, no Pi hook surface.
@@ -0,0 +1,155 @@
1
+ # Task 2097 — `email-signature-set` design
2
+
3
+ Date 2026-07-28. Lane *Platform / email · signature provisioning*.
4
+ Task file: [`.tasks/pending/2097-email-signature-setter-so-the-deterministic-loader-can-be-populated.md`](../../../../.tasks/pending/2097-email-signature-setter-so-the-deterministic-loader-can-be-populated.md).
5
+
6
+ ## Problem
7
+
8
+ `email-draft` appends an account's signature deterministically, with no model judgement, at
9
+ [`compose.ts:113`](../../../plugins/email/mcp/src/lib/compose.ts). It calls `loadSignature(sigDir)` then
10
+ `applySignature`, where `loadSignature` (Task 1952,
11
+ [`signature.ts:24`](../../../plugins/email/mcp/src/lib/signature.ts)) reads three fixed-convention files at the
12
+ account root: `email-signature.txt`, `email-signature.html`, `email-signature.font`.
13
+
14
+ Nothing in the platform writes those files. Task 1952 shipped the reader with no writer, so the
15
+ path-and-format convention lives only in code and no account can be provisioned. On the G.L. Smith
16
+ account none of the three exist, `applied` was false, no signature was appended, and a drafting
17
+ subagent with no file-read tool reconstructed the HTML from a memory node and pulled a stale logo
18
+ into a client draft.
19
+
20
+ The failure is not a model ignoring a rule. The deterministic path works; it had no files to read.
21
+
22
+ ## Outcome
23
+
24
+ An account's signature is installed into the loader's exact path and format through a sanctioned,
25
+ discoverable tool, and every subsequent draft carries it byte-for-byte with no model-authored
26
+ signature markup.
27
+
28
+ ## Architecture
29
+
30
+ `lib/signature.ts` becomes the single owner of the signature convention in both directions. Read and
31
+ write cannot drift because one file defines the base path, the three extensions, and the font
32
+ trimming for both.
33
+
34
+ | Unit | Responsibility | Depends on |
35
+ |---|---|---|
36
+ | `lib/signature.ts` → `signatureDirFor(accountId)` | Resolve the one directory an account's signature lives in | `attachment-resolve.ts` → `accountDirFor`, `process.env.ACCOUNT_DIR` |
37
+ | `lib/signature.ts` → `saveSignature(accountDir, parts)` | Write present parts, remove absent ones, report both | `node:fs` |
38
+ | `lib/signature.ts` → `loadSignature` / `applySignature` | Unchanged (Task 1952) | — |
39
+ | `tools/email-signature-set.ts` | Param refusals, call `saveSignature`, log, format the response | `lib/signature.ts` |
40
+ | `lib/compose.ts` | Swaps its inline dir expression for `signatureDirFor` | `lib/signature.ts` |
41
+
42
+ ### Directory resolution
43
+
44
+ `compose.ts:113` currently resolves the read directory inline as
45
+ `accountDirFor(accountId) || process.env.ACCOUNT_DIR`. The setter must write to the identical
46
+ directory in every environment, so that expression moves into `signatureDirFor(accountId)` and both
47
+ sides call it. Substituting the identical expression leaves `compose.ts` behaviour unchanged; the
48
+ point is that "which directory" now has one definition rather than two copies that can diverge.
49
+
50
+ Writing to `accountDirFor(accountId)` alone was rejected: on a spawn where `PLATFORM_ROOT` is absent
51
+ but `ACCOUNT_DIR` is set, the setter would refuse while drafts would have read the file fine.
52
+
53
+ ## Behaviour
54
+
55
+ Parameters, all optional strings: `text`, `html`, `font`. The account is the caller's `accountId`,
56
+ the same authority `email-draft` uses. There is no `mailbox` parameter — a signature is account-scoped,
57
+ because `loadSignature` reads one account root and knows nothing about mailboxes.
58
+
59
+ | Input | Effect on `email-signature.<ext>` |
60
+ |---|---|
61
+ | `text` present | `.txt` written byte-for-byte verbatim, no reflow, no re-encoding |
62
+ | `html` present | `.html` written byte-for-byte verbatim |
63
+ | `font` present | `.font` written trimmed, matching `loadSignature`'s trim-on-read |
64
+ | any one absent | that file removed if it exists; absence of the file is not an error |
65
+ | all three absent | refused, nothing on disk is touched |
66
+ | directory unresolvable | refused, nothing on disk is touched |
67
+
68
+ Absent and empty are different inputs. A parameter is absent only when it is not supplied; a supplied
69
+ empty string is present, so it writes an empty file and counts toward the all-three-absent check. That
70
+ follows from writing verbatim: the tool stores what it was given rather than second-guessing it. The
71
+ consequence is that an empty `.txt` makes the next draft report `applied` true while appending
72
+ nothing visible, which is why an empty string is a deliberate act and omission is the way to remove.
73
+
74
+ The removal rule is what makes a text-only call clear a stale `.html` rather than leave it to keep
75
+ appending. The all-three-absent refusal is what stops a mis-shaped call silently wiping a working
76
+ signature; removal stays reachable, but only as a side effect of setting something.
77
+
78
+ The response names the resolved paths written and the resolved paths removed, so the caller sees the
79
+ account root it landed in rather than trusting it.
80
+
81
+ ## Failure handling
82
+
83
+ Files are written in the fixed order `.txt`, `.html`, `.font`. A filesystem error surfaces verbatim
84
+ naming the offending path and the paths already written, so a half-applied state is reported rather
85
+ than hidden. There is no transactional rollback: the task does not ask for one, and reaching the
86
+ partial state requires a filesystem fault mid-call.
87
+
88
+ ## Observability
89
+
90
+ `[email-signature-set] op=write accountId=<first-8> files=<written> removed=<removed>` on every
91
+ successful write. The task specifies the line through `files=`; `removed=` is added because the task
92
+ mandates removal as a behaviour and mandates that provisioning be auditable, and an unlogged removal
93
+ is not auditable.
94
+
95
+ The existing `[email-draft] op=signature applied=<bool> asset=<path>` line stays the per-draft
96
+ signal. An account that has run the setter shows `applied=true` on its next draft. That pair is the
97
+ end-to-end evidence: a write line naming the files, then a draft line reporting them applied.
98
+
99
+ ## Registration
100
+
101
+ `ADMIN_CORE_TOOLS` is named in the task file but does not exist — `grep -rn 'ADMIN_CORE_TOOLS\s*[:=]'`
102
+ over `platform/` returns nothing, and the name survives only in comments. The live surfaces are:
103
+
104
+ 1. `mcp/src/index.ts` — `eagerTool(server, name, description, zodShape, handler)`.
105
+ 2. `PLUGIN.md` — a `tools:` entry (`riskClass: write_local`, `publicAllowlist: false`,
106
+ `adminAllowlist: false`, matching `email-setup`), a clause in the frontmatter `description:` prose,
107
+ an operator-docs bullet, and a skills-table row for the new skill.
108
+ 3. `platform/templates/specialists/agents/personal-assistant.md` — the `tools:` frontmatter grant, the
109
+ canonical per-tool permission surface since Task 453.
110
+
111
+ `riskClass: write_local` is correct because the tool writes account-local files and sends nothing
112
+ outbound, which is the same class `email-setup` carries.
113
+
114
+ ## Skill
115
+
116
+ `platform/plugins/email/skills/email-signature/SKILL.md`, activated when the operator asks to set or
117
+ change an email signature. It states the loader convention, and three standing rules:
118
+
119
+ - A signature is installed only through `email-signature-set`. Hand-writing the three files is not a
120
+ documented path.
121
+ - The agent never hand-builds a signature inside a draft body, because `email-draft` applies the
122
+ stored one automatically and a hand-built one both duplicates it and drifts from the asset.
123
+ - The signature content is read from the operator's own source of truth and passed through verbatim.
124
+ Reconstructing markup from memory or from stripped graph text is what put the stale logo in a
125
+ client draft.
126
+
127
+ Written as outcomes and constraints, not a step recipe, per `maxy-code/CLAUDE.md`.
128
+
129
+ ## Testing
130
+
131
+ Added to the plugin's existing vitest surface at `mcp/src/__tests__/`, not substituted for it.
132
+
133
+ - Round trip: set all three, `loadSignature` returns the exact values, `applySignature` reports
134
+ `applied` true.
135
+ - Verbatim: text and html land byte-identical, trailing newlines and all; font lands trimmed.
136
+ - Text-only call writes `.txt` and removes a pre-existing `.html` and `.font`, so a later draft
137
+ carries no stale HTML.
138
+ - All-three-absent call is refused and leaves pre-existing files untouched.
139
+ - Unresolvable directory is refused.
140
+ - A draft composed after a successful set carries body and html exactly equal to `applySignature`
141
+ output, with no model-authored signature text.
142
+ - `signatureDirFor` prefers `accountDirFor` and falls back to `ACCOUNT_DIR`.
143
+ - A supplied empty string writes an empty file and does not count as absent.
144
+ - Existing `signature.test.ts` stays green.
145
+
146
+ ## Out of scope
147
+
148
+ - Making `applied=false` a fail-closed refusal on client-facing drafts. Operator scoped this out on
149
+ 2026-07-28 as separate hardening; deliberately not filed.
150
+ - The one-off G.L. Smith provisioning and the stale memory-node deletion. That is a runtime action on
151
+ the live install, performed through the tool this task ships.
152
+ - Any change to `loadSignature` / `applySignature` behaviour (Task 1952). `signatureDirFor` moves an
153
+ expression out of `compose.ts`; neither function's behaviour changes.
154
+ - The drafts list/verify capability, which is
155
+ [Task 2098](../../../../.tasks/pending/2098-email-drafts-list-verify-tool-so-draft-presence-is-confirmable.md).
@@ -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:69ee9681cbe3bd78fa8d38f916aa5979d4814681b59f3c1bd3570c613315ca7c
4
+ content-hash: sha256:5704ae2d754fce29e10cf6bb2ee38da35a78387a0ca67134d5a105f1cd004abb
5
5
  brand: sitedesk-code
6
6
  product-name: SiteDesk
7
7
  ---
@@ -1294,6 +1294,8 @@ 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
+ The image form's **Timezone** dropdown offers only netcup's own datacentre zones (`Europe/Berlin`, `Europe/Vienna`), so an install serving any other region cannot set its real timezone here. Leave the default and correct it in step 2 with `timedatectl set-timezone <zone>`. This is not cosmetic: the scheduler and cron fire on system local time, so a UK install left on Berlin runs an hour early. **Locale** is a free choice on the same form and should be set there; the fleet uses `en_US.UTF-8`.
1298
+
1297
1299
  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
1300
 
1299
1301
  ```bash
@@ -3246,7 +3248,7 @@ either is a regression.
3246
3248
  | `/sessions` | Legacy admin-server conversation routes. No UI consumer remains after the ConversationsModal was retired; the surviving handlers are deletion candidates and not described here. | (legacy, no live caller) |
3247
3249
  | `/sidebar-sessions` | Sole data path for the sidebar Sessions list. One JSONL on disk equals one row. The row's delete button is the only way a row disappears. Each row carries `sessionId`, `title`, `startedAt`, `live`, `isSubagent`, `pid: number \| null` (basename of the matched `sessions/<pid>.json`), and `projectDir` (the directory holding the JSONL — consumed by the delete route). The payload also carries top-level `accountId` so the pane renders the full UUID label whose first ~8 chars prefix-match the truncated Remote Control daemon entry in claude.ai/code. The legacy `rcUrl` field is gone — the row's external-link affordance now POSTs `/session-rc-spawn` to start a fresh local `claude --remote-control <name> --session-id <sid>` PTY on every click. **WhatsApp and Telegram sessions (admin + public) appear in this list**, colour-coded by channel (WhatsApp green `#25D366`, Telegram blue `#229ED9`, via a `conv-channel-*` left accent bar) and carrying the standard row kebab (Usage & cost, Reset/re-seat, Rename, Archive, Delete) so the operator can inspect and re-seat a channel session's params. Each row's `channel` is the authoritative `.meta.json` sidecar value (`whatsapp`/`telegram`/`webchat`/`browser`/null), not the JSONL-envelope channel. Each row also carries its sidecar `senderId`. For a WhatsApp/Telegram row with a `senderId` and no operator rename or ai-title (`titleSource:'prefix'`), the route resolves a contact `personName` and the sidebar renders the reader panel's `"<name> <number>"` label via the shared `operatorConvName`, degrading to the number alone then the stripped title on a miss. On a `whatsapp` row the number is printed in international form (`+44 7504 472444`, `+33 7 68 24 30 67`) by `formatSenderPhone` — `normalizeE164` then libphonenumber-js `formatInternational`, gated on `isPossible` so an unresolved LID prints raw; a `telegram` senderId is a Telegram user id and a `webchat` one is `session:<id>`, so neither is regrouped. The formatting is display-only: row identity, matching, and store keys stay on the normalized digits. Because the label function is shared, a Sessions row and that conversation's reader-panel row read identically. A channel row name has two sources, in precedence order. First the shared `resolveOperatorNamesBySender` helper the WhatsApp reader also calls, which matches the sender phone against the account's admins — that names an admin WhatsApp session and every Telegram session. Second, for a `whatsapp` row only, the WhatsApp pushName from the account's on-disk message store (`storePushNamesBySender` over `readConversationSummaries`, keyed by `normalizeE164` of the counterparty phone so a `+44 7…` sender matches a `447…` store key). The store is consulted only when the resolver reports **no binding** for that sender. That is typically a public WhatsApp customer, the case this exists for — such a sender has no admin record and so can only be named by the store, and without it those rows degraded to the bare number while the reader panel named them — though an admin missing from `users.json` qualifies too. A resolver miss caused by Neo4j being unreachable deliberately does NOT qualify: that is an outage rather than an absent binding, so the row degrades to the number and still logs `reason=neo4j-down`, keeping the outage signal intact. Telegram has no message store and is never looked up. The store read is guarded: a corrupt or absent store degrades to no pushName and logs `op=store-unreadable`, never 500s the list. Both sources are the ones the reader itself uses, so the two surfaces cannot drift; a renamed or ai-titled channel row keeps its own title. The Sessions list reads the viewed account's own store only. An account-manager's thread persists under the house account rather than the sub-account it manages, so on a multi-account install viewing a sub-account that row is not named here even though the reader panel names it — the reader additionally consults the house store, filtered to manager phones bound to that sub. The only role/channel excluded from the list is `public`+`webchat` (its dedicated reader is the sole surface); the exclusion predicate is `isSessionListExcluded`, decoupled from the reader's inclusion predicate `isReaderChannelSession` (unchanged), so the WhatsApp/Telegram reader panels are unaffected — a store-only WhatsApp conversation with no session JSONL stays reader-only. Observability rides `[admin-sessions-list] … channelInList=whatsapp:<n>,telegram:<n>,webchat:<n> channelNameCandidates=<n> channelNamesResolved=<n> (operator=<n> store=<n>) unknownChannel=<n> excludedPublicWebchat=<n>`, and each unresolved channel name logs one `[admin-sessions-list] op=channel-name-unresolved sessionId=<id8> channel=<whatsapp\|telegram> senderId=<id> reason=<no-senderId\|classify-miss\|neo4j-down>` line — emitted only when **both** sources miss, so a residual shortfall means a genuinely nameless sender rather than the expected public-customer floor, and the `operator=`/`store=` split says which source is carrying the names. | `GET /` |
3248
3250
  | `/session-delete` | POST `{ sessionId }`. Thin proxy over the manager: POST `/:id/stop` (idempotent) then DELETE `/:id`. Sends **no** accountId. The manager resolves the session by scanning every slug under `projects/` for its JSONL (`slugForExistingJsonl`, covering `archive/`), exactly as the sidebar list enumerates rows, and gates on file existence alone — so any session the operator can see is deletable. This replaces the earlier accountId-derived slug and the `!row` term, which narrowed resolution below the list and left a session under a non-house slug with no watcher row (e.g. a legacy `-home-admin` session on a single-account install) listed yet un-deletable. Account scope is a view filter, not a delete gate — admin access is install-wide, so there is nothing to scope against. The sibling `/session-stop` (the standalone End control) resolves identically and also forwards no accountId. | `POST /` |
3249
- | `/session-rc-spawn` | POST `{ sessionId?, name? }`. Fire-and-forget `claude --remote-control [name] [--session-id <sid>]`. Present `sessionId` resumes; absent starts a fresh session (also used by the sidebar's "New session" button — it no longer opens claude.ai/code directly). Proxies to the manager's `/rc-spawn`, which waits up to **60 s** (raised from 12 s) for the spawned PTY to bind and returns `{ spawnedPid, sessionId, bridgeSessionId, slug, outcome, reason }`. For a webchat-bound spawn (every admin-gated host's "New session", returning a same-origin `/chat?session=<id>` target — `resolveRcSpawnOutcome` → `sameOrigin:true`) the Sidebar navigates the **current** tab via `window.location.assign`, replacing the dashboard in place (back returns to it); only a claude.ai/code slug (`sameOrigin:false`, the bare-admin resume bridge, never a new-session outcome) navigates a separately-opened tab. On `timeout` or `spawn-failed` it shows an error modal (reason + sessionId) and **never** opens a bare claude.ai/code tab. The new process registers itself as its own Remote Control entry in claude.ai/code. | `POST /` |
3251
+ | `/session-rc-spawn` | POST `{ sessionId?, name?, title?, model?, effort? }`. Fire-and-forget `claude --remote-control [name] [--session-id <sid>]`. Present `sessionId` resumes; absent starts a fresh session (also used by the sidebar's "New session" button — it no longer opens claude.ai/code directly). Proxies to the manager's `/rc-spawn`, which waits up to **60 s** (raised from 12 s) for the spawned PTY to bind and returns `{ spawnedPid, sessionId, bridgeSessionId, slug, outcome, reason }`. For a webchat-bound spawn (every admin-gated host's "New session", returning a same-origin `/chat?session=<id>` target — `resolveRcSpawnOutcome` → `sameOrigin:true`) the Sidebar navigates the **current** tab via `window.location.assign`, replacing the dashboard in place (back returns to it); only a claude.ai/code slug (`sameOrigin:false`, the bare-admin resume bridge, never a new-session outcome) navigates a separately-opened tab. On `timeout` or `spawn-failed` it shows an error modal (reason + sessionId) and **never** opens a bare claude.ai/code tab. The new process registers itself as its own Remote Control entry in claude.ai/code. **Naming.** `title` is the operator's name for a NEW session, typed in the naming modal that gates all four New-session controls (sidebar CTA, composer popover, zero-sessions splash, Conversations flyout); nothing is POSTed until the operator confirms it, and the modal prefills `New session: <date, time>`. `resolveSpawnPlan` trims it, it never rides the manager spawn payload, and this route writes it to the user-title store through the manager's `POST /:id/rename` **before it answers**, so the client cannot navigate ahead of the write; the response gains `titleSource: 'user' \| 'none'`. A resume carries no title. A rejected or unreachable rename leaves the spawn intact and returns `'none'`. Lifeline: `[chat-spawn] op=title spawnReqId=… sessionId=… titleLen=… source=modal\|absent outcome=applied\|rejected\|failed`, where `source=absent` on a new spawn is the standing signal that a control bypassed the modal. | `POST /` |
3250
3252
  | `/claude-sessions` | **Spawn surface only**. `POST /` is the Sidebar new-session-with-prompt path, cookie-auth only (the recorder loopback caller was removed; LinkedIn ingest moved to `/rc-spawn`). The former UI-facing handlers (SSE row feed, list, resume, stop, rename, archive, delete, `/:id/meta`, `/:id/input`, `/:id/log`) were removed — the maxy dashboard no longer manages or displays sessions. | `POST /` |
3251
3253
 
3252
3254
  **Admin session management moved entirely to claude's own interfaces** (claude.ai/code, claude desktop). A manager-owned per-account `claude rc --spawn same-dir` daemon registers the device as a Remote Control target there; the composer creates / resumes / stops / renames / archives / deletes sessions, with model + permission-mode applied at inception. The model lever is `account.json.adminModel` → `CLAUDE_CONFIG_DIR/settings.json "model"`, written by the daemon supervisor at boot; the reasoning-effort lever is `account.json.effort` → `settings.json "effortLevel"` (accepted values `low|medium|high|xhigh`; any other value, e.g. legacy `"auto"`, leaves the key unset so claude's own default governs), written by the same read-merge-write at boot. A third inception lever, permission mode, is added: `account.json.adminPermissionMode` → `settings.json "permissions.defaultMode"` (the 5 composer-writable modes `default|acceptEdits|plan|auto|bypassPermissions`; `dontAsk` is out of scope). Unlike model/effort (top-level keys), this lever **deep-merges** the nested `permissions.defaultMode`, owning only that key and preserving sibling `permissions` keys (allow/deny/…) — what the binary and the spawn lifeline actually read. **For admin sessions, permission mode is fixed (Bypass) and not operator-chosen, while model and effort ARE operator-chosen at launch** via the composer's `+` new-session popover — a radio model list (Opus / Sonnet / Haiku) plus a 5-stop Faster→Smarter effort slider (Low / Medium / High / Extra / Max, where `max` applies via the `--effort` argv rather than `settings.json`); the popover description lives in `.docs/admin-webchat-native-channel.md`. Absent a pick (and on every resume), the daemon-pinned defaults govern: the `/chat` composer *footer* carries no live re-seat pickers — `rc-daemon.ts` pins settings.json `model`/`effortLevel`/`permissions.defaultMode` to `claude-opus-5[1m]` / `medium` / `bypassPermissions` regardless of `account.json` (read by the rc-spawn webchat path), and `pty-spawner.ts` forces the claude.ai/code PTY `--permission-mode` to `bypassPermissions` for `role=admin`. Public sessions are fixed to `claude-sonnet-5` (the `/public-spawn` chokepoint + `buildPublicWebchatSpawnRequest`), keeping `dontAsk`. **WhatsApp admin sessions override the daemon default:** the `/rc-spawn` handler pins `claude-sonnet-5` / `--effort max` / `bypassPermissions` for a `role=admin` WhatsApp spawn (constants `WHATSAPP_ADMIN_MODEL`/`WHATSAPP_ADMIN_EFFORT` in `http-server.ts`), pushing `--model`/`--effort` on both the fresh spawn and every resume so the params hold for the session's whole life. The override is scoped to sessions *born* under it: a fresh WhatsApp admin spawn stamps its classification sidecar `model=claude-sonnet-5`, and a resume re-asserts Sonnet/max only when that stamp is present — so a WhatsApp session created before this change (sidecar `model=null`) finishes its life on the Opus/medium default. Webchat and Telegram admin sessions are unaffected; public WhatsApp inbound never spawns. The composer footer instead renders a **read-only** params row (mode · model · effort labels) plus a context-window usage figure (`pct% · ~used/~window`), all sourced from the session pointer; the full path is in `.docs/admin-webchat-native-channel.md`. The composer cannot re-seat a session, so the "no transcript" 400 on a brand-new chat is unreachable; the `/api/admin/session-reseat` + `/api/webchat/settings` routes and the dashboard re-seat form stay as off-composer surfaces. When the live session runs in `default` ("Ask permissions") mode, a tool the agent attempts that needs approval surfaces an Allow/Deny card in the composer (tool, description, argument preview); a click sends the verdict over Claude Code's channel permission relay and the tool runs or is refused — webchat is the only interactive ask surface (WhatsApp stays text-only; Telegram has no agent channel). Beyond the composer, the maxy admin UI keeps a single "New session" link (`https://claude.ai/code`, opens in a new tab) and no separate session list, viewer, controls, or model/mode picker. The daemon supervisor lives at [`platform/services/claude-session-manager/src/rc-daemon.ts`](../../../services/claude-session-manager/src/rc-daemon.ts). The `/session-defaults` route and `SpawnPreference` node were deleted with the picker. `/new-session-failure`, `/new-session-submit`, and `/claude-capabilities` are now orphaned (consumed only by the deleted NewSessionModal) — see [`.tasks/501`](../../../.tasks/) for their removal.
@@ -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.521)
13
+
14
+ - Starting a new session now opens a naming modal first, and New-session controls read white on black across every brand.
15
+ - WhatsApp conversation reader now identifies group message senders by their real JID, and a turn no longer reports a still-in-flight attachment as missing.
16
+ - A stale pending-interactive entry no longer sends its narration to the customer; the calendar attachment diagnostic now names the flat single-part shape it used to drop.
17
+
18
+ ## 2026-07-28 (0.1.520)
19
+
20
+ - The admin now lands on the Dashboard, with a Public pages card linking the booking site and file-drop portal.
21
+ - The scheduling read surface now returns meetings alongside events, so a note-only meeting update explains why instead of saying not found.
22
+ - New email tools: a signature setter to populate the deterministic signature loader, and a drafts list to confirm a draft is really there.
23
+
12
24
  ## 2026-07-28 (0.1.519)
13
25
 
14
26
  - 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).
@@ -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 () => {