@rubytech/create-sitedesk-code 0.1.519 → 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 (289) 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/whats-new/SKILL.md +6 -0
  5. package/payload/platform/plugins/cloudflare/bin/portal-assemble.mjs +187 -0
  6. package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +46 -5
  7. package/payload/platform/plugins/cloudflare/mcp/__tests__/auth-route.test.ts +19 -7
  8. package/payload/platform/plugins/cloudflare/mcp/__tests__/authorize.test.ts +154 -38
  9. package/payload/platform/plugins/cloudflare/mcp/__tests__/file-routes.test.ts +100 -29
  10. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-assemble.test.ts +143 -0
  11. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-delete-withdraw.test.ts +12 -5
  12. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts +15 -3
  13. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +105 -11
  14. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +15 -2
  15. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-moved-download.test.ts +9 -5
  16. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-schema-shape.test.ts +47 -5
  17. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-session-grant.test.ts +26 -2
  18. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-target.test.ts +12 -4
  19. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-upload-multipart.test.ts +23 -15
  20. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-upload-target.test.ts +23 -15
  21. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-uploads-listing.test.ts +8 -0
  22. package/payload/platform/plugins/cloudflare/mcp/__tests__/skill-contract.test.ts +23 -6
  23. package/payload/platform/plugins/cloudflare/mcp/__tests__/upload-route.test.ts +36 -20
  24. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +85 -22
  25. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +47 -7
  26. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/authorize.ts +27 -18
  27. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/ratelimit.ts +10 -3
  28. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/session.ts +27 -11
  29. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/target.ts +18 -9
  30. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/types.ts +16 -0
  31. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/auth.ts +16 -4
  32. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/delete.ts +26 -11
  33. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +14 -10
  34. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +13 -9
  35. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/upload.ts +42 -22
  36. package/payload/platform/plugins/cloudflare/skills/data-portal/template/wrangler.toml +17 -4
  37. package/payload/platform/plugins/email/.claude-plugin/plugin.json +1 -1
  38. package/payload/platform/plugins/email/PLUGIN.md +20 -1
  39. package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-list-tool.test.d.ts +2 -0
  40. package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-list-tool.test.d.ts.map +1 -0
  41. package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-list-tool.test.js +142 -0
  42. package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-list-tool.test.js.map +1 -0
  43. package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-list.test.d.ts +2 -0
  44. package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-list.test.d.ts.map +1 -0
  45. package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-list.test.js +153 -0
  46. package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-list.test.js.map +1 -0
  47. package/payload/platform/plugins/email/mcp/dist/__tests__/email-signature-set.test.d.ts +2 -0
  48. package/payload/platform/plugins/email/mcp/dist/__tests__/email-signature-set.test.d.ts.map +1 -0
  49. package/payload/platform/plugins/email/mcp/dist/__tests__/email-signature-set.test.js +203 -0
  50. package/payload/platform/plugins/email/mcp/dist/__tests__/email-signature-set.test.js.map +1 -0
  51. package/payload/platform/plugins/email/mcp/dist/index.js +69 -0
  52. package/payload/platform/plugins/email/mcp/dist/index.js.map +1 -1
  53. package/payload/platform/plugins/email/mcp/dist/lib/compose.d.ts.map +1 -1
  54. package/payload/platform/plugins/email/mcp/dist/lib/compose.js +5 -7
  55. package/payload/platform/plugins/email/mcp/dist/lib/compose.js.map +1 -1
  56. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts +42 -0
  57. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts.map +1 -1
  58. package/payload/platform/plugins/email/mcp/dist/lib/imap.js +76 -0
  59. package/payload/platform/plugins/email/mcp/dist/lib/imap.js.map +1 -1
  60. package/payload/platform/plugins/email/mcp/dist/lib/signature.d.ts +38 -0
  61. package/payload/platform/plugins/email/mcp/dist/lib/signature.d.ts.map +1 -1
  62. package/payload/platform/plugins/email/mcp/dist/lib/signature.js +67 -2
  63. package/payload/platform/plugins/email/mcp/dist/lib/signature.js.map +1 -1
  64. package/payload/platform/plugins/email/mcp/dist/tools/email-draft-list.d.ts +10 -0
  65. package/payload/platform/plugins/email/mcp/dist/tools/email-draft-list.d.ts.map +1 -0
  66. package/payload/platform/plugins/email/mcp/dist/tools/email-draft-list.js +73 -0
  67. package/payload/platform/plugins/email/mcp/dist/tools/email-draft-list.js.map +1 -0
  68. package/payload/platform/plugins/email/mcp/dist/tools/email-signature-set.d.ts +18 -0
  69. package/payload/platform/plugins/email/mcp/dist/tools/email-signature-set.d.ts.map +1 -0
  70. package/payload/platform/plugins/email/mcp/dist/tools/email-signature-set.js +38 -0
  71. package/payload/platform/plugins/email/mcp/dist/tools/email-signature-set.js.map +1 -0
  72. package/payload/platform/plugins/email/references/email-reference.md +14 -2
  73. package/payload/platform/plugins/email/skills/email-composition/SKILL.md +10 -0
  74. package/payload/platform/plugins/email/skills/email-signature/SKILL.md +61 -0
  75. package/payload/platform/plugins/scheduling/PLUGIN.md +8 -0
  76. package/payload/platform/plugins/scheduling/mcp/dist/index.js +26 -15
  77. package/payload/platform/plugins/scheduling/mcp/dist/index.js.map +1 -1
  78. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/schedule-audit.test.d.ts +2 -0
  79. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/schedule-audit.test.d.ts.map +1 -0
  80. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/schedule-audit.test.js +48 -0
  81. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/schedule-audit.test.js.map +1 -0
  82. package/payload/platform/plugins/scheduling/mcp/dist/lib/schedule-audit.d.ts +24 -0
  83. package/payload/platform/plugins/scheduling/mcp/dist/lib/schedule-audit.d.ts.map +1 -0
  84. package/payload/platform/plugins/scheduling/mcp/dist/lib/schedule-audit.js +33 -0
  85. package/payload/platform/plugins/scheduling/mcp/dist/lib/schedule-audit.js.map +1 -0
  86. package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js +22 -1
  87. package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js.map +1 -1
  88. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-cancel-meeting.test.d.ts +2 -0
  89. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-cancel-meeting.test.d.ts.map +1 -0
  90. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-cancel-meeting.test.js +30 -0
  91. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-cancel-meeting.test.js.map +1 -0
  92. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-get-meeting.test.d.ts +2 -0
  93. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-get-meeting.test.d.ts.map +1 -0
  94. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-get-meeting.test.js +43 -0
  95. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-get-meeting.test.js.map +1 -0
  96. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-list.test.d.ts +2 -0
  97. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-list.test.d.ts.map +1 -0
  98. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-list.test.js +87 -0
  99. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-list.test.js.map +1 -0
  100. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-update-meeting.test.d.ts +2 -0
  101. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-update-meeting.test.d.ts.map +1 -0
  102. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-update-meeting.test.js +48 -0
  103. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-update-meeting.test.js.map +1 -0
  104. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-cancel.d.ts.map +1 -1
  105. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-cancel.js +14 -1
  106. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-cancel.js.map +1 -1
  107. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-get.d.ts +4 -1
  108. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-get.d.ts.map +1 -1
  109. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-get.js +39 -2
  110. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-get.js.map +1 -1
  111. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-list.d.ts +4 -1
  112. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-list.d.ts.map +1 -1
  113. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-list.js +50 -1
  114. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-list.js.map +1 -1
  115. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-update.d.ts.map +1 -1
  116. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-update.js +43 -1
  117. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-update.js.map +1 -1
  118. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
  119. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +2 -0
  120. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
  121. package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -1
  122. package/payload/server/public/activity.html +5 -5
  123. package/payload/server/public/agents.html +4 -4
  124. package/payload/server/public/assets/AdminLoginScreens-SW7mEMcP.js +1 -0
  125. package/payload/server/public/assets/AdminLoginScreens-SW7mEMcP.js.br +0 -0
  126. package/payload/server/public/assets/AdminLoginScreens-SW7mEMcP.js.gz +0 -0
  127. package/payload/server/public/assets/{AdminShell-Bpo_wgxm.js → AdminShell-Djg6ndne.js} +1 -1
  128. package/payload/server/public/assets/AdminShell-Djg6ndne.js.br +0 -0
  129. package/payload/server/public/assets/AdminShell-Djg6ndne.js.gz +0 -0
  130. package/payload/server/public/assets/{activity-DOsfKzT3.js → activity-sqbyX0pX.js} +1 -1
  131. package/payload/server/public/assets/activity-sqbyX0pX.js.br +0 -0
  132. package/payload/server/public/assets/activity-sqbyX0pX.js.gz +0 -0
  133. package/payload/server/public/assets/admin-D9Bt47GY.js +1 -0
  134. package/payload/server/public/assets/admin-D9Bt47GY.js.br +0 -0
  135. package/payload/server/public/assets/admin-D9Bt47GY.js.gz +0 -0
  136. package/payload/server/public/assets/{agents-DqDnZTH_.js → agents-BFYM5EDA.js} +1 -1
  137. package/payload/server/public/assets/agents-BFYM5EDA.js.br +0 -0
  138. package/payload/server/public/assets/agents-BFYM5EDA.js.gz +0 -0
  139. package/payload/server/public/assets/{browser-FL17Dfp0.js → browser-DP5GZe4G.js} +1 -1
  140. package/payload/server/public/assets/browser-DP5GZe4G.js.br +0 -0
  141. package/payload/server/public/assets/browser-DP5GZe4G.js.gz +0 -0
  142. package/payload/server/public/assets/{calendar-CLfLvsnV.js → calendar-BKaemLDG.js} +1 -1
  143. package/payload/server/public/assets/calendar-BKaemLDG.js.br +0 -0
  144. package/payload/server/public/assets/calendar-BKaemLDG.js.gz +0 -0
  145. package/payload/server/public/assets/chat-DPhT6urD.js +1 -0
  146. package/payload/server/public/assets/chat-DPhT6urD.js.br +1 -0
  147. package/payload/server/public/assets/chat-DPhT6urD.js.gz +0 -0
  148. package/payload/server/public/assets/chevron-left-BSdXvJcH.js +1 -0
  149. package/payload/server/public/assets/chevron-right-DI7vI3IB.js +1 -0
  150. package/payload/server/public/assets/chevron-right-DI7vI3IB.js.br +0 -0
  151. package/payload/server/public/assets/clock-YUTPXZH5.js +1 -0
  152. package/payload/server/public/assets/clock-YUTPXZH5.js.br +0 -0
  153. package/payload/server/public/assets/clock-YUTPXZH5.js.gz +0 -0
  154. package/payload/server/public/assets/data-CfjttTvR.js +1 -0
  155. package/payload/server/public/assets/data-CfjttTvR.js.br +2 -0
  156. package/payload/server/public/assets/data-CfjttTvR.js.gz +0 -0
  157. package/payload/server/public/assets/{file-text-DNm0AzUz.js → file-text-BKK7R-k_.js} +1 -1
  158. package/payload/server/public/assets/file-text-BKK7R-k_.js.br +0 -0
  159. package/payload/server/public/assets/file-text-BKK7R-k_.js.gz +0 -0
  160. package/payload/server/public/assets/{graph-fAvmlVzh.js → graph-FuxYySjK.js} +1 -1
  161. package/payload/server/public/assets/graph-FuxYySjK.js.br +0 -0
  162. package/payload/server/public/assets/graph-FuxYySjK.js.gz +0 -0
  163. package/payload/server/public/assets/{graph-labels-CwdkCxfs.js → graph-labels-Cip6fmKK.js} +1 -1
  164. package/payload/server/public/assets/graph-labels-Cip6fmKK.js.br +0 -0
  165. package/payload/server/public/assets/graph-labels-Cip6fmKK.js.gz +0 -0
  166. package/payload/server/public/assets/{maximize-2-B6tAPhC2.js → maximize-2-D79AhVDo.js} +1 -1
  167. package/payload/server/public/assets/maximize-2-D79AhVDo.js.br +0 -0
  168. package/payload/server/public/assets/maximize-2-D79AhVDo.js.gz +0 -0
  169. package/payload/server/public/assets/{operator-CTKvBVjk.js → operator-BPAfgFhP.js} +1 -1
  170. package/payload/server/public/assets/operator-BPAfgFhP.js.br +0 -0
  171. package/payload/server/public/assets/operator-BPAfgFhP.js.gz +0 -0
  172. package/payload/server/public/assets/{page-BWoNW3sa.js → page--78CbdFs.js} +1 -1
  173. package/payload/server/public/assets/page--78CbdFs.js.br +0 -0
  174. package/payload/server/public/assets/page--78CbdFs.js.gz +0 -0
  175. package/payload/server/public/assets/{page-O5HkHQw4.js → page-BqEyS0W4.js} +1 -1
  176. package/payload/server/public/assets/page-BqEyS0W4.js.br +0 -0
  177. package/payload/server/public/assets/page-BqEyS0W4.js.gz +0 -0
  178. package/payload/server/public/assets/{public-BF1rnVPa.js → public-D1xs3FzD.js} +1 -1
  179. package/payload/server/public/assets/public-D1xs3FzD.js.br +0 -0
  180. package/payload/server/public/assets/public-D1xs3FzD.js.gz +0 -0
  181. package/payload/server/public/assets/{rotate-ccw-B8TqfCy4.js → rotate-ccw-CcU_XD8B.js} +1 -1
  182. package/payload/server/public/assets/rotate-ccw-CcU_XD8B.js.br +0 -0
  183. package/payload/server/public/assets/rotate-ccw-CcU_XD8B.js.gz +0 -0
  184. package/payload/server/public/assets/{routines-4R7BLZyH.js → routines-CA7D0myr.js} +1 -1
  185. package/payload/server/public/assets/routines-CA7D0myr.js.br +0 -0
  186. package/payload/server/public/assets/routines-CA7D0myr.js.gz +0 -0
  187. package/payload/server/public/assets/{skills-CQ9Qs2tg.js → skills-CfVPPSxO.js} +1 -1
  188. package/payload/server/public/assets/skills-CfVPPSxO.js.br +0 -0
  189. package/payload/server/public/assets/skills-CfVPPSxO.js.gz +0 -0
  190. package/payload/server/public/assets/{tasks-DePyOrRk.js → tasks-DgieyZqz.js} +1 -1
  191. package/payload/server/public/assets/tasks-DgieyZqz.js.br +0 -0
  192. package/payload/server/public/assets/tasks-DgieyZqz.js.gz +0 -0
  193. package/payload/server/public/assets/{time-entry-format-D9gtnG01.js → time-entry-format-IFqinQJj.js} +1 -1
  194. package/payload/server/public/assets/time-entry-format-IFqinQJj.js.br +0 -0
  195. package/payload/server/public/assets/time-entry-format-IFqinQJj.js.gz +0 -0
  196. package/payload/server/public/assets/{triangle-alert-DAtm2mLL.js → triangle-alert-C5vH__sq.js} +1 -1
  197. package/payload/server/public/assets/triangle-alert-C5vH__sq.js.br +0 -0
  198. package/payload/server/public/assets/triangle-alert-C5vH__sq.js.gz +0 -0
  199. package/payload/server/public/assets/{useCopyFeedback-uMJT-p6i.js → useCopyFeedback-BeL2gPl6.js} +1 -1
  200. package/payload/server/public/assets/useCopyFeedback-BeL2gPl6.js.br +0 -0
  201. package/payload/server/public/assets/useCopyFeedback-BeL2gPl6.js.gz +0 -0
  202. package/payload/server/public/assets/{useSubAccountSwitcher-iCzsV96x.css → useSubAccountSwitcher-DLqUQzOl.css} +1 -1
  203. package/payload/server/public/assets/useSubAccountSwitcher-DLqUQzOl.css.br +0 -0
  204. package/payload/server/public/assets/useSubAccountSwitcher-DLqUQzOl.css.gz +0 -0
  205. package/payload/server/public/assets/{useVoiceRecorder-BFrPC-s7.js → useVoiceRecorder-Cpw3aOSt.js} +1 -1
  206. package/payload/server/public/assets/useVoiceRecorder-Cpw3aOSt.js.br +0 -0
  207. package/payload/server/public/assets/useVoiceRecorder-Cpw3aOSt.js.gz +0 -0
  208. package/payload/server/public/assets/{wrench-CYgaN7K9.js → wrench-BBEup3hd.js} +1 -1
  209. package/payload/server/public/assets/wrench-BBEup3hd.js.br +0 -0
  210. package/payload/server/public/assets/wrench-BBEup3hd.js.gz +0 -0
  211. package/payload/server/public/browser.html +4 -4
  212. package/payload/server/public/calendar.html +7 -7
  213. package/payload/server/public/chat.html +13 -13
  214. package/payload/server/public/data.html +11 -11
  215. package/payload/server/public/graph.html +9 -9
  216. package/payload/server/public/index.html +14 -14
  217. package/payload/server/public/operator.html +14 -14
  218. package/payload/server/public/public.html +13 -13
  219. package/payload/server/public/routines.html +6 -6
  220. package/payload/server/public/skills.html +5 -5
  221. package/payload/server/public/tasks.html +6 -6
  222. package/payload/server/server.js +454 -352
  223. package/payload/server/public/assets/AdminLoginScreens-BZPbCk8g.js +0 -1
  224. package/payload/server/public/assets/AdminLoginScreens-BZPbCk8g.js.br +0 -0
  225. package/payload/server/public/assets/AdminLoginScreens-BZPbCk8g.js.gz +0 -0
  226. package/payload/server/public/assets/AdminShell-Bpo_wgxm.js.br +0 -0
  227. package/payload/server/public/assets/AdminShell-Bpo_wgxm.js.gz +0 -0
  228. package/payload/server/public/assets/activity-DOsfKzT3.js.br +0 -0
  229. package/payload/server/public/assets/activity-DOsfKzT3.js.gz +0 -0
  230. package/payload/server/public/assets/admin-COgD1z5a.js +0 -1
  231. package/payload/server/public/assets/admin-COgD1z5a.js.br +0 -0
  232. package/payload/server/public/assets/admin-COgD1z5a.js.gz +0 -0
  233. package/payload/server/public/assets/agents-DqDnZTH_.js.br +0 -0
  234. package/payload/server/public/assets/agents-DqDnZTH_.js.gz +0 -0
  235. package/payload/server/public/assets/browser-FL17Dfp0.js.br +0 -0
  236. package/payload/server/public/assets/browser-FL17Dfp0.js.gz +0 -0
  237. package/payload/server/public/assets/calendar-CLfLvsnV.js.br +0 -0
  238. package/payload/server/public/assets/calendar-CLfLvsnV.js.gz +0 -0
  239. package/payload/server/public/assets/chat-DYq4FxMG.js +0 -1
  240. package/payload/server/public/assets/chat-DYq4FxMG.js.br +0 -0
  241. package/payload/server/public/assets/chat-DYq4FxMG.js.gz +0 -0
  242. package/payload/server/public/assets/chevron-left-DWt5cdUo.js +0 -1
  243. package/payload/server/public/assets/chevron-right-B9suVrYy.js +0 -1
  244. package/payload/server/public/assets/chevron-right-B9suVrYy.js.br +0 -0
  245. package/payload/server/public/assets/clock-DW6qVXtv.js +0 -1
  246. package/payload/server/public/assets/clock-DW6qVXtv.js.br +0 -0
  247. package/payload/server/public/assets/clock-DW6qVXtv.js.gz +0 -0
  248. package/payload/server/public/assets/data-C8rbOgds.js +0 -1
  249. package/payload/server/public/assets/data-C8rbOgds.js.br +0 -0
  250. package/payload/server/public/assets/data-C8rbOgds.js.gz +0 -0
  251. package/payload/server/public/assets/file-text-DNm0AzUz.js.br +0 -0
  252. package/payload/server/public/assets/file-text-DNm0AzUz.js.gz +0 -0
  253. package/payload/server/public/assets/graph-fAvmlVzh.js.br +0 -0
  254. package/payload/server/public/assets/graph-fAvmlVzh.js.gz +0 -0
  255. package/payload/server/public/assets/graph-labels-CwdkCxfs.js.br +0 -0
  256. package/payload/server/public/assets/graph-labels-CwdkCxfs.js.gz +0 -0
  257. package/payload/server/public/assets/maximize-2-B6tAPhC2.js.br +0 -0
  258. package/payload/server/public/assets/maximize-2-B6tAPhC2.js.gz +0 -0
  259. package/payload/server/public/assets/operator-CTKvBVjk.js.br +0 -0
  260. package/payload/server/public/assets/operator-CTKvBVjk.js.gz +0 -0
  261. package/payload/server/public/assets/page-BWoNW3sa.js.br +0 -0
  262. package/payload/server/public/assets/page-BWoNW3sa.js.gz +0 -0
  263. package/payload/server/public/assets/page-O5HkHQw4.js.br +0 -0
  264. package/payload/server/public/assets/page-O5HkHQw4.js.gz +0 -0
  265. package/payload/server/public/assets/public-BF1rnVPa.js.br +0 -0
  266. package/payload/server/public/assets/public-BF1rnVPa.js.gz +0 -0
  267. package/payload/server/public/assets/rotate-ccw-B8TqfCy4.js.br +0 -0
  268. package/payload/server/public/assets/rotate-ccw-B8TqfCy4.js.gz +0 -0
  269. package/payload/server/public/assets/routines-4R7BLZyH.js.br +0 -0
  270. package/payload/server/public/assets/routines-4R7BLZyH.js.gz +0 -0
  271. package/payload/server/public/assets/skills-CQ9Qs2tg.js.br +0 -0
  272. package/payload/server/public/assets/skills-CQ9Qs2tg.js.gz +0 -0
  273. package/payload/server/public/assets/tasks-DePyOrRk.js.br +0 -0
  274. package/payload/server/public/assets/tasks-DePyOrRk.js.gz +0 -0
  275. package/payload/server/public/assets/time-entry-format-D9gtnG01.js.br +0 -0
  276. package/payload/server/public/assets/time-entry-format-D9gtnG01.js.gz +0 -0
  277. package/payload/server/public/assets/triangle-alert-DAtm2mLL.js.br +0 -0
  278. package/payload/server/public/assets/triangle-alert-DAtm2mLL.js.gz +0 -0
  279. package/payload/server/public/assets/useCopyFeedback-uMJT-p6i.js.br +0 -0
  280. package/payload/server/public/assets/useCopyFeedback-uMJT-p6i.js.gz +0 -0
  281. package/payload/server/public/assets/useSubAccountSwitcher-iCzsV96x.css.br +0 -0
  282. package/payload/server/public/assets/useSubAccountSwitcher-iCzsV96x.css.gz +0 -0
  283. package/payload/server/public/assets/useVoiceRecorder-BFrPC-s7.js.br +0 -0
  284. package/payload/server/public/assets/useVoiceRecorder-BFrPC-s7.js.gz +0 -0
  285. package/payload/server/public/assets/wrench-CYgaN7K9.js.br +0 -1
  286. package/payload/server/public/assets/wrench-CYgaN7K9.js.gz +0 -0
  287. /package/payload/server/public/assets/{useSubAccountSwitcher-BufCPDDH.js → useSubAccountSwitcher-CH9qm2Ad.js} +0 -0
  288. /package/payload/server/public/assets/{useSubAccountSwitcher-BufCPDDH.js.br → useSubAccountSwitcher-CH9qm2Ad.js.br} +0 -0
  289. /package/payload/server/public/assets/{useSubAccountSwitcher-BufCPDDH.js.gz → useSubAccountSwitcher-CH9qm2Ad.js.gz} +0 -0
@@ -0,0 +1,143 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ // @ts-expect-error - .mjs helper with JSDoc types, imported for its pure core
5
+ import { assembleTree, parseEnvFile } from '../../bin/portal-assemble.mjs'
6
+
7
+ // The real template, not a hand-written copy: a test that invented its own
8
+ // placeholder set would keep passing after the template gained one.
9
+ const TEMPLATE = readFileSync(
10
+ join(__dirname, '../../skills/data-portal/template/wrangler.toml'),
11
+ 'utf8',
12
+ )
13
+
14
+ const shared = {
15
+ DATA_PORTAL_DB_NAME: 'house-data-portal',
16
+ DATA_PORTAL_DB_ID: 'db-123',
17
+ DATA_PORTAL_BUCKET: 'house-data-portal',
18
+ }
19
+ const cfg = {
20
+ accountId: 'acct-a',
21
+ projectName: 'ecolec-data-portal',
22
+ portalDomain: 'ecolec-data.example',
23
+ storageModel: 'shared',
24
+ }
25
+ const installOrigin = 'https://pub.example'
26
+
27
+ describe('portal assemble (Task 2099)', () => {
28
+ it('fills every placeholder from the shared identity and the account', () => {
29
+ const out = assembleTree({ toml: TEMPLATE, shared, cfg, installOrigin })
30
+ expect(out).toContain('database_name = "house-data-portal"')
31
+ expect(out).toContain('database_id = "db-123"')
32
+ expect(out).toContain('bucket_name = "house-data-portal"')
33
+ expect(out).toContain('PORTAL_ACCOUNT_ID = "acct-a"')
34
+ expect(out).toContain('name = "ecolec-data-portal"')
35
+ expect(out).toContain('PORTAL_INSTALL_ORIGIN = "https://pub.example"')
36
+ })
37
+
38
+ it('leaves no placeholder behind, which is the whole post-condition', () => {
39
+ const out = assembleTree({ toml: TEMPLATE, shared, cfg, installOrigin })
40
+ expect(out).not.toMatch(/__[A-Z0-9_]+__/)
41
+ })
42
+
43
+ it('gives two portals the same store and different account vars', () => {
44
+ // The shape of the shared model: one store, many portals, and the account
45
+ // var is the only thing that differs between two assembled trees.
46
+ const a = assembleTree({ toml: TEMPLATE, shared, cfg, installOrigin })
47
+ const b = assembleTree({
48
+ toml: TEMPLATE,
49
+ shared,
50
+ cfg: { ...cfg, accountId: 'acct-b', projectName: 'other-data-portal' },
51
+ installOrigin,
52
+ })
53
+ expect(a).toContain('database_id = "db-123"')
54
+ expect(b).toContain('database_id = "db-123"')
55
+ expect(a).toContain('PORTAL_ACCOUNT_ID = "acct-a"')
56
+ expect(b).toContain('PORTAL_ACCOUNT_ID = "acct-b"')
57
+ })
58
+
59
+ for (const key of ['DATA_PORTAL_DB_NAME', 'DATA_PORTAL_DB_ID', 'DATA_PORTAL_BUCKET']) {
60
+ it(`refuses when ${key} is missing rather than emitting a blank binding`, () => {
61
+ expect(() =>
62
+ assembleTree({ toml: TEMPLATE, shared: { ...shared, [key]: '' }, cfg, installOrigin }),
63
+ ).toThrow(new RegExp(key))
64
+ })
65
+ }
66
+
67
+ it('refuses a grandfathered account, which this template cannot serve', () => {
68
+ // Its database has no accountId column and every query here binds one, so
69
+ // assembling would deploy a portal that errors on the first request.
70
+ expect(() =>
71
+ assembleTree({ toml: TEMPLATE, shared, cfg: { ...cfg, storageModel: 'dedicated' }, installOrigin }),
72
+ ).toThrow(/dedicated/)
73
+ })
74
+
75
+ it('treats an absent storageModel as dedicated, never as shared', () => {
76
+ const { storageModel, ...noModel } = cfg
77
+ expect(() => assembleTree({ toml: TEMPLATE, shared, cfg: noModel, installOrigin })).toThrow(
78
+ /dedicated/,
79
+ )
80
+ })
81
+
82
+ it('refuses an unrecognised storage model rather than guessing', () => {
83
+ expect(() =>
84
+ assembleTree({ toml: TEMPLATE, shared, cfg: { ...cfg, storageModel: 'Shared' }, installOrigin }),
85
+ ).toThrow(/Shared/)
86
+ })
87
+
88
+ it('refuses a config missing its account or project', () => {
89
+ expect(() =>
90
+ assembleTree({ toml: TEMPLATE, shared, cfg: { ...cfg, accountId: '' }, installOrigin }),
91
+ ).toThrow(/accountId/)
92
+ expect(() =>
93
+ assembleTree({ toml: TEMPLATE, shared, cfg: { ...cfg, projectName: '' }, installOrigin }),
94
+ ).toThrow(/projectName/)
95
+ })
96
+
97
+ it('refuses an empty install origin', () => {
98
+ expect(() => assembleTree({ toml: TEMPLATE, shared, cfg, installOrigin: '' })).toThrow(/origin/)
99
+ })
100
+
101
+ it('refuses when the config account disagrees with the account directory', () => {
102
+ // PORTAL_ACCOUNT_ID is baked in permanently and becomes the worker's tenant
103
+ // scope, but the house-side loops scope by the DIRECTORY name. If the two
104
+ // differ the worker writes under one tenant while the pull, index push and
105
+ // audit read under another, and nothing reports it: the reads just return
106
+ // nothing and the client's uploads never arrive.
107
+ expect(() =>
108
+ assembleTree({ toml: TEMPLATE, shared, cfg, installOrigin, expectedAccountId: 'acct-b' }),
109
+ ).toThrow(/acct-a.*acct-b|acct-b.*acct-a/s)
110
+ })
111
+
112
+ it('accepts when they agree', () => {
113
+ const out = assembleTree({ toml: TEMPLATE, shared, cfg, installOrigin, expectedAccountId: 'acct-a' })
114
+ expect(out).toContain('PORTAL_ACCOUNT_ID = "acct-a"')
115
+ })
116
+
117
+ it('refuses an accountId the deployed key gate would reject', () => {
118
+ // authorizeKey enforces [a-z0-9-] on both key segments, so baking anything
119
+ // else means every upload 403s after deploy instead of failing here.
120
+ for (const bad of ['Acct-A', 'acct_a', 'acct a', 'acct/a']) {
121
+ expect(() =>
122
+ assembleTree({ toml: TEMPLATE, shared, cfg: { ...cfg, accountId: bad }, installOrigin }),
123
+ ).toThrow(/\[a-z0-9-\]/)
124
+ }
125
+ })
126
+ })
127
+
128
+ describe('parseEnvFile', () => {
129
+ it('reads KEY=value, trims the value, and ignores comments and blanks', () => {
130
+ const env = parseEnvFile('# c\n\nA=1\n B=2 \nnot a pair\n')
131
+ expect(env.A).toBe('1')
132
+ expect(env.B).toBe('2')
133
+ expect(env['not a pair']).toBeUndefined()
134
+ })
135
+
136
+ it('does not accept a space before the equals, matching readHouseCredential', () => {
137
+ // Deliberately the same regex as lib/storage-broker's house-credential
138
+ // reader, because both parse the SAME file. A looser parser here would
139
+ // accept a line the broker silently ignores, so the two would disagree
140
+ // about what the house config says.
141
+ expect(parseEnvFile('B = 2\n').B).toBeUndefined()
142
+ })
143
+ })
@@ -10,6 +10,9 @@ function harness(row: { devicePath: string } | null) {
10
10
  const lines: string[] = []
11
11
 
12
12
  const env = {
13
+ // The portal's own account, baked in at assemble time. It scopes every
14
+ // manifest statement and is the first segment of every key below.
15
+ PORTAL_ACCOUNT_ID: 'acct',
13
16
  BUCKET: {
14
17
  async delete() {
15
18
  state.objectDeleted = true
@@ -25,6 +28,8 @@ function harness(row: { devicePath: string } | null) {
25
28
  return {
26
29
  async first() {
27
30
  if (sql.includes('FROM sessions')) {
31
+ // The people row's account has to match the portal's, or
32
+ // resolveSession returns null and every case below 401s.
28
33
  return { ownerId: 'alice', accountId: 'acct', folders: '' }
29
34
  }
30
35
  if (sql.includes('SELECT devicePath')) return row
@@ -51,7 +56,7 @@ function harness(row: { devicePath: string } | null) {
51
56
  describe('delete branches on whether the file has moved', () => {
52
57
  it('deletes object and row outright when the file is still in transit', async () => {
53
58
  const h = harness({ devicePath: '' })
54
- const res = await processDelete('s', 'alice/a.pdf', h.env, h.log, () => 1)
59
+ const res = await processDelete('s', 'acct/alice/a.pdf', h.env, h.log, () => 1)
55
60
  expect(res.status).toBe(200)
56
61
  expect(h.state.objectDeleted).toBe(true)
57
62
  expect(h.state.rowDeleted).toBe(true)
@@ -63,7 +68,7 @@ describe('delete branches on whether the file has moved', () => {
63
68
  // here would report success while the client's file stayed on the device
64
69
  // forever — the exact failure this branch exists to prevent.
65
70
  const h = harness({ devicePath: 'jobs/a.pdf' })
66
- const res = await processDelete('s', 'alice/jobs/a.pdf', h.env, h.log, () => 1)
71
+ const res = await processDelete('s', 'acct/alice/jobs/a.pdf', h.env, h.log, () => 1)
67
72
  expect(res.status).toBe(200)
68
73
  expect(h.state.marked).toBe(true)
69
74
  expect(h.state.rowDeleted).toBe(false)
@@ -73,13 +78,13 @@ describe('delete branches on whether the file has moved', () => {
73
78
 
74
79
  it('tells the client the withdrawal is pending, not complete', async () => {
75
80
  const h = harness({ devicePath: 'jobs/a.pdf' })
76
- const res = await processDelete('s', 'alice/jobs/a.pdf', h.env, h.log, () => 1)
81
+ const res = await processDelete('s', 'acct/alice/jobs/a.pdf', h.env, h.log, () => 1)
77
82
  expect(res.payload).toMatchObject({ ok: true, pending: true })
78
83
  })
79
84
 
80
85
  it('reports an unknown key as not found without writing anything', async () => {
81
86
  const h = harness(null)
82
- const res = await processDelete('s', 'alice/nope.pdf', h.env, h.log, () => 1)
87
+ const res = await processDelete('s', 'acct/alice/nope.pdf', h.env, h.log, () => 1)
83
88
  expect(res.status).toBe(404)
84
89
  expect(h.state.objectDeleted).toBe(false)
85
90
  expect(h.state.marked).toBe(false)
@@ -87,8 +92,10 @@ describe('delete branches on whether the file has moved', () => {
87
92
  })
88
93
 
89
94
  it('still refuses another person key before reading anything', async () => {
95
+ // Same account, different owner segment: the owner half of the check is
96
+ // what has to reject this one.
90
97
  const h = harness({ devicePath: '' })
91
- const res = await processDelete('s', 'bob/a.pdf', h.env, h.log, () => 1)
98
+ const res = await processDelete('s', 'acct/bob/a.pdf', h.env, h.log, () => 1)
92
99
  expect(res.status).toBe(403)
93
100
  expect(h.state.objectDeleted).toBe(false)
94
101
  })
@@ -21,6 +21,10 @@ function makeEnv(
21
21
  onQuery: (query: string) => void = () => {},
22
22
  ) {
23
23
  return {
24
+ // The tenant scope, baked into wrangler.toml at assemble time (Task 2099).
25
+ // One shared store backs every portal, so this is what the directory read
26
+ // binds, and what the session's own account has to agree with.
27
+ PORTAL_ACCOUNT_ID: 'acc-a',
24
28
  DB: {
25
29
  prepare(query: string) {
26
30
  onQuery(query)
@@ -34,12 +38,17 @@ function makeEnv(
34
38
  return { meta: { changes: 0 } }
35
39
  },
36
40
  async all<T>() {
41
+ // The directory read binds the portal's own account id, so `bound[0]`
42
+ // is PORTAL_ACCOUNT_ID rather than anything off the session.
37
43
  if (/FROM directory/i.test(query)) {
38
44
  return { results: DIRECTORY.filter((d) => d.accountId === bound[0]) as T[] }
39
45
  }
40
46
  return { results: [] as T[] }
41
47
  },
42
48
  async first<T>() {
49
+ // resolveSession binds (sessionId, accountId, nowMs) and reads the
50
+ // sessions/people join, so the row's `accountId` is the person's.
51
+ // It has to equal the portal's or the session does not resolve.
43
52
  if (/FROM sessions/i.test(query)) return (sessions[bound[0] as string] ?? null) as T | null
44
53
  return null as T | null
45
54
  },
@@ -149,11 +158,14 @@ describe('processFiles directory listing', () => {
149
158
  expect(JSON.stringify(r.payload)).not.toContain('secret.pdf')
150
159
  })
151
160
 
152
- it('returns an empty entries list when the person has no account bound', async () => {
161
+ // A person with no account bound used to resolve and simply see no folders.
162
+ // On the shared store an absent tenant is a contradiction, not a narrower
163
+ // scope, so resolveSession fails closed and nothing is listed at all.
164
+ it('denies when the person has no account bound', async () => {
153
165
  const env = makeEnv({ 'sess-a': { ownerId: 'alice', accountId: '' } })
154
166
  const r = await processFiles('sess-a', env, () => {}, 0, '')
155
- expect(r.status).toBe(200)
156
- expect(r.payload.entries).toEqual([])
167
+ expect(r.status).toBe(401)
168
+ expect(r.payload).toEqual({ ok: false, error: 'denied' })
157
169
  })
158
170
 
159
171
  it('still denies without a session', async () => {
@@ -12,10 +12,11 @@ import {
12
12
  const run = promisify(execFile)
13
13
  const SCRIPT = resolve(dirname(fileURLToPath(import.meta.url)), '../../bin/portal-enrol.mjs')
14
14
 
15
- /** `--account` is required (it binds the person to the sub-account whose folders
16
- * they may read), but it is irrelevant to the passcode behaviour most of these
17
- * tests cover, so the helper supplies one unless the caller names its own. Tests
18
- * that assert the requirement itself call `run` directly. */
15
+ /** `--account` is required (it names the portal's own account, which is half the
16
+ * person's key on the shared store and decides whose folders they read), but it
17
+ * is irrelevant to the passcode behaviour most of these tests cover, so the
18
+ * helper supplies one unless the caller names its own. Tests that assert the
19
+ * requirement itself call `run` directly. */
19
20
  const enrol = (args: string[]) =>
20
21
  run('node', [SCRIPT, ...(args.includes('--account') ? args : [...args, '--account', 'acc-1'])])
21
22
 
@@ -173,6 +174,72 @@ describe('portal-enrol', () => {
173
174
  await expect(enrol(['--owner', 'alice', '--name', 'Bob\nDROP'])).rejects.toThrow()
174
175
  })
175
176
 
177
+ describe('the conflict target follows the storage model (Task 2099)', () => {
178
+ /** A pre-2099 portal's own database: `people` keyed on ownerId alone. */
179
+ async function legacyDb(dir: string) {
180
+ const { mkdtemp } = await import('node:fs/promises')
181
+ const { tmpdir } = await import('node:os')
182
+ const root = await mkdtemp(resolve(tmpdir(), dir))
183
+ const db = resolve(root, 'portal.db')
184
+ await run('sqlite3', [
185
+ db,
186
+ 'CREATE TABLE people (ownerId TEXT PRIMARY KEY, name TEXT NOT NULL, salt TEXT NOT NULL, ' +
187
+ "hash TEXT NOT NULL, createdAt TEXT NOT NULL, accountId TEXT NOT NULL DEFAULT '', " +
188
+ "folders TEXT NOT NULL DEFAULT '');",
189
+ ])
190
+ return {
191
+ exec: (sql: string) => run('sqlite3', [db, sql]),
192
+ query: async (sql: string) => (await run('sqlite3', [db, sql])).stdout.trim(),
193
+ }
194
+ }
195
+
196
+ it('lets two tenants each enrol the same person on the shared store', async () => {
197
+ const db = await d1('enrol-two-tenants-')
198
+ await db.exec(parse(await enrol(['--owner', 'rob', '--name', 'Rob', '--account', 'acct-a'])).sql!)
199
+ await db.exec(parse(await enrol(['--owner', 'rob', '--name', 'Rob', '--account', 'acct-b'])).sql!)
200
+ expect(await db.query('SELECT COUNT(*) FROM people;')).toBe('2')
201
+ })
202
+
203
+ it('still rotates on a grandfathered database, which the composite target cannot', async () => {
204
+ // The regression this test exists for. A pre-2099 people table has no
205
+ // unique index on (accountId, ownerId), and SQLite does not degrade: it
206
+ // refuses the whole statement with "ON CONFLICT clause does not match any
207
+ // PRIMARY KEY or UNIQUE constraint". That would take passcode rotation
208
+ // away from every existing portal, and rotation is the one remedy for a
209
+ // leaked passcode.
210
+ const db = await legacyDb('enrol-legacy-')
211
+ const first = await enrol(['--owner', 'dale', '--name', 'Dale', '--account', 'acct-a', '--storage-model', 'dedicated'])
212
+ await db.exec(parse(first).sql!)
213
+ const second = await enrol(['--owner', 'dale', '--name', 'Dale', '--account', 'acct-a', '--storage-model', 'dedicated'])
214
+ await db.exec(parse(second).sql!)
215
+
216
+ expect(await db.query('SELECT COUNT(*) FROM people;')).toBe('1')
217
+ const [salt, hash] = (await db.query("SELECT salt, hash FROM people WHERE ownerId='dale';")).split('|')
218
+ expect(await verifyPasscode(parse(second).passcode!, salt, hash)).toBe(true)
219
+ expect(await verifyPasscode(parse(first).passcode!, salt, hash)).toBe(false)
220
+ })
221
+
222
+ it('names the composite target only on the shared model', async () => {
223
+ const shared = parse(await enrol(['--owner', 'a', '--name', 'A', '--account', 'acct-a'])).sql!
224
+ const dedicated = parse(
225
+ await enrol(['--owner', 'a', '--name', 'A', '--account', 'acct-a', '--storage-model', 'dedicated']),
226
+ ).sql!
227
+ expect(shared).toContain('ON CONFLICT(accountId, ownerId)')
228
+ expect(dedicated).toContain('ON CONFLICT(ownerId)')
229
+ // The dedicated path keeps moving a person between sub-accounts, exactly
230
+ // as it did before 2099. The shared path must not: accountId is half the
231
+ // key there, so it cannot also be a field the match rewrites.
232
+ expect(dedicated).toContain('accountId=excluded.accountId')
233
+ expect(shared).not.toContain('accountId=excluded.accountId')
234
+ })
235
+
236
+ it('refuses an unrecognised storage model', async () => {
237
+ await expect(
238
+ enrol(['--owner', 'a', '--name', 'A', '--account', 'acct-a', '--storage-model', 'Shared']),
239
+ ).rejects.toThrow()
240
+ })
241
+ })
242
+
176
243
  describe('against a real SQLite database built from schema.sql', () => {
177
244
  it('applies cleanly', async () => {
178
245
  const db = await d1('enrol-apply-')
@@ -183,9 +250,10 @@ describe('portal-enrol', () => {
183
250
 
184
251
  it('rotates a passcode by re-running, which is the ONLY sanctioned rotation path', async () => {
185
252
  // SKILL.md: "Rotating a passcode is a re-run of the script for that person."
186
- // A bare INSERT cannot do that ownerId is TEXT PRIMARY KEY, so the second
187
- // run dies on a UNIQUE violation in a SEPARATE process, AFTER the script has
188
- // already printed a new passcode and exited 0. The operator hands out a
253
+ // A bare INSERT cannot do that: (accountId, ownerId) is the primary key, so
254
+ // the second run for the same person at the same portal dies on a UNIQUE
255
+ // violation in a SEPARATE process, AFTER the script has already printed a
256
+ // new passcode and exited 0. The operator hands out a
189
257
  // passcode that never worked while the leaked one still does, and pcCheck
190
258
  // revocation never fires. Rotation is exactly the case where a remedy that
191
259
  // silently no-ops is worse than no remedy.
@@ -223,13 +291,39 @@ describe('portal-enrol', () => {
223
291
  await db.exec(parse(await enrol(['--owner', 'alice', '--name', 'Alice', '--account', 'acc-a'])).sql!)
224
292
  expect(await db.query("SELECT accountId FROM people WHERE ownerId='alice';")).toBe('acc-a')
225
293
 
226
- // Re-enrolling to a different account moves the binding rather than
227
- // leaving the person pointed at folders they no longer belong to.
228
- await db.exec(parse(await enrol(['--owner', 'alice', '--name', 'Alice', '--account', 'acc-b'])).sql!)
229
- expect(await db.query("SELECT accountId FROM people WHERE ownerId='alice';")).toBe('acc-b')
294
+ // Re-enrolling the same person at the same portal is the rotation path, and
295
+ // the binding survives it: accountId is half the conflict key, so the DO
296
+ // UPDATE no longer names it and cannot blank it back to the column default.
297
+ await db.exec(parse(await enrol(['--owner', 'alice', '--name', 'Alice', '--account', 'acc-a'])).sql!)
298
+ expect(await db.query("SELECT accountId FROM people WHERE ownerId='alice';")).toBe('acc-a')
230
299
  expect(await db.query('SELECT COUNT(*) FROM people;')).toBe('1')
231
300
  })
232
301
 
302
+ it('enrols the same ownerId at a second portal as a separate person', async () => {
303
+ // The shared store holds every portal's people, so two clients may each
304
+ // enrol someone called `alice` (Task 2099). Keyed on ownerId alone the
305
+ // second enrolment would read as a rotation of the first client's person
306
+ // and overwrite their passcode, their grant, and the ownership of their
307
+ // files; keyed on (accountId, ownerId) it is a different person entirely.
308
+ // `--account` names the portal, so re-running with a different one no
309
+ // longer MOVES a person, and neither passcode is disturbed by the other.
310
+ const db = await d1('enrol-account-split-')
311
+ const a = await enrol(['--owner', 'alice', '--name', 'Alice', '--account', 'acc-a'])
312
+ await db.exec(parse(a).sql!)
313
+ const b = await enrol(['--owner', 'alice', '--name', 'Alice', '--account', 'acc-b'])
314
+ await db.exec(parse(b).sql!)
315
+
316
+ expect(await db.query("SELECT accountId FROM people WHERE ownerId='alice' ORDER BY accountId;"))
317
+ .toBe('acc-a\nacc-b')
318
+ expect(await db.query('SELECT COUNT(*) FROM people;')).toBe('2')
319
+
320
+ const [saltA, hashA] = (
321
+ await db.query("SELECT salt, hash FROM people WHERE ownerId='alice' AND accountId='acc-a';")
322
+ ).split('|')
323
+ expect(await verifyPasscode(parse(a).passcode!, saltA, hashA)).toBe(true)
324
+ expect(await verifyPasscode(parse(b).passcode!, saltA, hashA)).toBe(false)
325
+ })
326
+
233
327
  it('declares a directory table keyed uniquely on account and path', async () => {
234
328
  const db = await d1('enrol-directory-')
235
329
  await db.exec(
@@ -35,11 +35,15 @@ function makeEnv(
35
35
  },
36
36
  async first<T>() {
37
37
  if (/FROM sessions/i.test(query)) {
38
+ // The people row carries this portal's own account, which is what
39
+ // resolveSession requires: on the shared store a session resolves
40
+ // only against the account baked into PORTAL_ACCOUNT_ID.
38
41
  return (bound[0] === 'sess-a'
39
42
  ? { ownerId: 'alice', accountId, folders }
40
43
  : null) as T | null
41
44
  }
42
45
  if (/FROM directory/i.test(query)) {
46
+ // Bound (accountId, relPath), so the path is the second bind.
43
47
  const rel = bound[1] as string
44
48
  if (dirPaths.includes(rel)) return { relPath: rel, isDir: 1 } as T
45
49
  return (paths.includes(rel) ? { relPath: rel, isDir: 0 } : null) as T | null
@@ -50,6 +54,9 @@ function makeEnv(
50
54
  return stmt
51
55
  },
52
56
  },
57
+ // This portal's own tenant scope, baked into wrangler.toml at assemble time.
58
+ // Every query and every object key binds it; the store itself is shared.
59
+ PORTAL_ACCOUNT_ID: accountId,
53
60
  PORTAL_FETCH_SECRET: 'secret' in opts ? opts.secret : 'a-secret',
54
61
  PORTAL_INSTALL_ORIGIN: 'origin' in opts ? opts.origin : 'https://install.example',
55
62
  } as unknown as PortalEnv
@@ -154,7 +161,12 @@ describe('processIndexedDownload', () => {
154
161
  expect(r.status).toBe(401)
155
162
  })
156
163
 
157
- it('404s when the person has no account bound', async () => {
164
+ // The account is the portal's own PORTAL_ACCOUNT_ID now, not a property of the
165
+ // person, so "no account bound" means an unconfigured portal. That is caught a
166
+ // step earlier than it used to be: a session resolves only against this
167
+ // portal's account and the empty string is never a match, so the refusal reads
168
+ // as denied. Either way no link is minted against an unscoped store.
169
+ it('refuses when the portal has no account bound', async () => {
158
170
  const r = await processIndexedDownload(
159
171
  'sess-a',
160
172
  PATH,
@@ -163,7 +175,8 @@ describe('processIndexedDownload', () => {
163
175
  1000,
164
176
  okHead as never,
165
177
  )
166
- expect(r.status).toBe(404)
178
+ expect(r.status).toBe(401)
179
+ expect(r.url).toBeUndefined()
167
180
  })
168
181
 
169
182
  it('never mints a link when the secret or origin is unconfigured', async () => {
@@ -32,6 +32,10 @@ function harness(row: { devicePath: string } | null, opts: { configured?: boolea
32
32
  }
33
33
  },
34
34
  },
35
+ // Baked in at assemble time, and the tenant scope for the session lookup,
36
+ // the key check and the manifest read. The store is shared, so this is what
37
+ // separates this portal's rows and objects from another client's.
38
+ PORTAL_ACCOUNT_ID: ACC,
35
39
  PORTAL_FETCH_SECRET: configured ? 'test-secret-value' : undefined,
36
40
  PORTAL_INSTALL_ORIGIN: configured ? 'https://install.example' : undefined,
37
41
  } as unknown as PortalEnv
@@ -41,7 +45,7 @@ function harness(row: { devicePath: string } | null, opts: { configured?: boolea
41
45
  describe('downloading an upload that has moved to the device', () => {
42
46
  it('streams R2 bytes while the row is still in transit', async () => {
43
47
  const h = harness({ devicePath: '' })
44
- const res = await processDownload('s', 'alice/a.pdf', h.env, h.log, 1000)
48
+ const res = await processDownload('s', `${ACC}/alice/a.pdf`, h.env, h.log, 1000)
45
49
  expect(res.status).toBe(200)
46
50
  expect(res.body).not.toBeNull()
47
51
  expect(res.url).toBeUndefined()
@@ -51,7 +55,7 @@ describe('downloading an upload that has moved to the device', () => {
51
55
  // The object is gone from R2. Without this branch the client's own upload
52
56
  // would 404 the moment the device collected it.
53
57
  const h = harness({ devicePath: 'uploads/alice/a.pdf' })
54
- const res = await processDownload('s', 'alice/a.pdf', h.env, h.log, 1000)
58
+ const res = await processDownload('s', `${ACC}/alice/a.pdf`, h.env, h.log, 1000)
55
59
  expect(res.status).toBe(200)
56
60
  expect(res.url).toContain('/api/portal/fetch')
57
61
  expect(new URL(res.url!).searchParams.get('owner')).toBe('alice')
@@ -61,7 +65,7 @@ describe('downloading an upload that has moved to the device', () => {
61
65
 
62
66
  it('refuses another person key before anything is minted', async () => {
63
67
  const h = harness({ devicePath: 'uploads/bob/a.pdf' })
64
- const res = await processDownload('s', 'bob/a.pdf', h.env, h.log, 1000)
68
+ const res = await processDownload('s', `${ACC}/bob/a.pdf`, h.env, h.log, 1000)
65
69
  expect(res.status).toBe(403)
66
70
  expect(res.url).toBeUndefined()
67
71
  })
@@ -70,14 +74,14 @@ describe('downloading an upload that has moved to the device', () => {
70
74
  // Same reasoning as the indexed path: from the client's side the file
71
75
  // genuinely is unreachable, and the operator's signal is the log line.
72
76
  const h = harness({ devicePath: 'uploads/alice/a.pdf' }, { configured: false })
73
- const res = await processDownload('s', 'alice/a.pdf', h.env, h.log, 1000)
77
+ const res = await processDownload('s', `${ACC}/alice/a.pdf`, h.env, h.log, 1000)
74
78
  expect(res.status).toBe(503)
75
79
  expect(h.lines.some((l) => l.includes('result=unconfigured'))).toBe(true)
76
80
  })
77
81
 
78
82
  it('reports absent for a key with no manifest row at all', async () => {
79
83
  const h = harness(null)
80
- const res = await processDownload('s', 'alice/gone.pdf', h.env, h.log, 1000)
84
+ const res = await processDownload('s', `${ACC}/alice/gone.pdf`, h.env, h.log, 1000)
81
85
  expect(res.status).toBe(404)
82
86
  })
83
87
  })
@@ -10,6 +10,17 @@ const SQL = readFileSync(
10
10
  'utf8',
11
11
  )
12
12
 
13
+ // accountId appears on several tables, so asserting it against the whole file
14
+ // proves nothing about which table has it. No CREATE TABLE body in this schema
15
+ // contains a semicolon, so the statement terminator bounds the block.
16
+ const tableBody = (name: string): string => {
17
+ const found = SQL.match(
18
+ new RegExp(`CREATE TABLE IF NOT EXISTS ${name} \\(([^;]*)\\)`),
19
+ )
20
+ expect(found, `no CREATE TABLE for ${name}`).not.toBeNull()
21
+ return found![1]
22
+ }
23
+
13
24
  describe('portal schema shape', () => {
14
25
  it('carries the manifest lifecycle columns', () => {
15
26
  expect(SQL).toMatch(/targetPath\s+TEXT\s+NOT NULL\s+DEFAULT\s+''/)
@@ -23,11 +34,42 @@ describe('portal schema shape', () => {
23
34
  expect(SQL).toMatch(/currentGeneration\s+INTEGER\s+NOT NULL/)
24
35
  })
25
36
 
26
- it('keeps objectKey UNIQUE, which the upsert conflict target still needs', () => {
27
- // objectKey is now ownerId/targetPath/filename. The upsert still conflicts
28
- // on it, so this pins that the constraint was not dropped by accident while
29
- // the key shape changed underneath it.
30
- expect(SQL).toMatch(/objectKey\s+TEXT\s+UNIQUE\s+NOT NULL/)
37
+ it('scopes objectKey uniqueness by account, which the upsert conflict target now needs', () => {
38
+ // objectKey is now accountId/ownerId/targetPath/filename, and on the shared
39
+ // store it is unique only within an account. The upsert conflicts on
40
+ // (accountId, objectKey), so this pins that the constraint moved to the
41
+ // table level rather than being dropped while the key shape changed
42
+ // underneath it. The bare column-level UNIQUE must be gone: it is account
43
+ // blind, so one client uploading at another client's key would take over
44
+ // the existing row, reset its ingested flag, and redirect the file.
45
+ expect(SQL).toMatch(/objectKey\s+TEXT\s+NOT NULL/)
46
+ expect(SQL).not.toMatch(/objectKey\s+TEXT\s+UNIQUE/)
47
+ expect(SQL).toMatch(/UNIQUE\s*\(\s*accountId,\s*objectKey\s*\)/)
48
+ })
49
+
50
+ it('carries the tenant discriminator on every table holding client data', () => {
51
+ // One shared house-owned database backs every portal (Task 2099), so
52
+ // accountId is the only thing separating one client's rows from another's.
53
+ // A missing discriminator here is not a loud "no such column" like the
54
+ // lifecycle columns above; it is a silent cross-tenant read.
55
+ expect(tableBody('sessions')).toMatch(/accountId\s+TEXT\s+NOT NULL/)
56
+ expect(tableBody('manifest')).toMatch(/accountId\s+TEXT\s+NOT NULL/)
57
+ // people is keyed on the pair, not on ownerId alone: two clients may each
58
+ // enrol a person called `rob`, and a bare ownerId key would either reject
59
+ // the second enrolment or let its upsert overwrite the first client's
60
+ // person, handing over their passcode and their folder grant.
61
+ expect(tableBody('people')).toMatch(
62
+ /PRIMARY KEY\s*\(\s*accountId,\s*ownerId\s*\)/,
63
+ )
64
+ expect(tableBody('people')).not.toMatch(/ownerId\s+TEXT\s+PRIMARY KEY/)
65
+ // The pull and the audit both filter by account, so it carries its own
66
+ // index rather than riding the owner one.
67
+ expect(SQL).toMatch(
68
+ /CREATE INDEX IF NOT EXISTS manifest_account ON manifest \(accountId\)/,
69
+ )
70
+ // auth_attempts is the deliberate exception: its tenant rides inside the
71
+ // scope string, which is now accountId|ownerId|clientIp.
72
+ expect(tableBody('auth_attempts')).not.toMatch(/accountId/)
31
73
  })
32
74
 
33
75
  it('scopes the directory uniqueness by generation', () => {
@@ -32,20 +32,44 @@ describe('resolveSession folders', () => {
32
32
  const s = await resolveSession(
33
33
  db({ ownerId: 'alice', accountId: 'acc-a', folders: 'quotes,output' }),
34
34
  'sess',
35
+ 'acc-a',
35
36
  1,
36
37
  )
37
38
  expect(s?.folders).toEqual(['quotes', 'output'])
38
39
  })
39
40
 
40
41
  it('returns an empty array for an empty grant', async () => {
41
- const s = await resolveSession(db({ ownerId: 'alice', accountId: 'acc-a', folders: '' }), 'sess', 1)
42
+ const s = await resolveSession(
43
+ db({ ownerId: 'alice', accountId: 'acc-a', folders: '' }),
44
+ 'sess',
45
+ 'acc-a',
46
+ 1,
47
+ )
42
48
  expect(s?.folders).toEqual([])
43
49
  })
44
50
 
45
51
  it('returns an empty array when the column is absent', async () => {
46
- const s = await resolveSession(db({ ownerId: 'alice', accountId: 'acc-a' }), 'sess', 1)
52
+ const s = await resolveSession(db({ ownerId: 'alice', accountId: 'acc-a' }), 'sess', 'acc-a', 1)
47
53
  expect(s?.folders).toEqual([])
48
54
  })
55
+
56
+ it('refuses a row whose account disagrees with the portal own account', async () => {
57
+ // The query already scopes by account, so a row coming back for a different
58
+ // tenant is a contradiction. On a shared store the safe reading of a
59
+ // contradiction is nothing, never the row.
60
+ const s = await resolveSession(
61
+ db({ ownerId: 'alice', accountId: 'acc-b', folders: 'quotes' }),
62
+ 'sess',
63
+ 'acc-a',
64
+ 1,
65
+ )
66
+ expect(s).toBeNull()
67
+ })
68
+
69
+ it('refuses a legacy row that carries no account at all', async () => {
70
+ const s = await resolveSession(db({ ownerId: 'alice', folders: 'quotes' }), 'sess', 'acc-a', 1)
71
+ expect(s).toBeNull()
72
+ })
49
73
  })
50
74
 
51
75
  describe('grantAllows', () => {
@@ -48,13 +48,21 @@ describe('isSafeTargetSegment', () => {
48
48
  })
49
49
 
50
50
  describe('uploadKey', () => {
51
- it('collapses to owner/filename when untargeted', () => {
52
- expect(uploadKey('alice', '', 'a.pdf')).toBe('alice/a.pdf')
51
+ it('collapses to account/owner/filename when untargeted', () => {
52
+ expect(uploadKey('acct-a', 'alice', '', 'a.pdf')).toBe('acct-a/alice/a.pdf')
53
53
  })
54
54
 
55
55
  it('carries the target so two folders never collide', () => {
56
- expect(uploadKey('alice', 'jobs', 'a.pdf')).toBe('alice/jobs/a.pdf')
57
- expect(uploadKey('alice', 'jobs/2026', 'a.pdf')).toBe('alice/jobs/2026/a.pdf')
56
+ expect(uploadKey('acct-a', 'alice', 'jobs', 'a.pdf')).toBe('acct-a/alice/jobs/a.pdf')
57
+ expect(uploadKey('acct-a', 'alice', 'jobs/2026', 'a.pdf')).toBe('acct-a/alice/jobs/2026/a.pdf')
58
+ })
59
+
60
+ it('separates two tenants that share an owner id and a filename', () => {
61
+ // The store is shared and objectKey is unique per account, so this pair
62
+ // must differ or one client upload would take over the other client row.
63
+ expect(uploadKey('acct-a', 'alice', '', 'a.pdf')).not.toBe(
64
+ uploadKey('acct-b', 'alice', '', 'a.pdf'),
65
+ )
58
66
  })
59
67
  })
60
68