@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
@@ -1,8 +1,23 @@
1
1
  -- D1 schema for the data portal (data-portal skill).
2
2
  --
3
- -- Four tables: who may authenticate (people), who currently is (sessions),
4
- -- what they have dropped (manifest), and how hard they have been trying
5
- -- (auth_attempts). Everything is IF NOT EXISTS so re-applying is safe.
3
+ -- Six tables: who may authenticate (people), who currently is (sessions),
4
+ -- what they have dropped (manifest), how hard they have been trying
5
+ -- (auth_attempts), and what the device has published (directory,
6
+ -- directory_state). Everything is IF NOT EXISTS so re-applying is safe.
7
+ --
8
+ -- This schema is applied to the ONE shared, house-owned database that backs
9
+ -- every portal (Task 2099). `accountId` is therefore the tenant discriminator,
10
+ -- not a hint: it appears on every table carrying client data, the deployed
11
+ -- worker binds its own account into every query from the wrangler var
12
+ -- PORTAL_ACCOUNT_ID, and nothing else separates one client's rows from
13
+ -- another's. `auth_attempts` is the one exception and needs no column, because
14
+ -- the tenant rides inside its `scope` string (see _lib/ratelimit.ts).
15
+ --
16
+ -- A portal provisioned before 2099 has its OWN database with the older shape
17
+ -- and is never migrated in place. IF NOT EXISTS adds no column to an existing
18
+ -- table, so applying this file to such a database silently leaves it as it was.
19
+ -- That is exactly why the shared store must be a freshly created database, and
20
+ -- why those portals stay on their own template until migrated deliberately.
6
21
 
7
22
  -- One row per enrolled person. No plaintext passcode is ever stored: the
8
23
  -- enrolment script generates the passcode, prints it to the operator once, and
@@ -32,14 +47,20 @@
32
47
  -- to a `people` table that predates it, and enrolment's INSERT names `folders`,
33
48
  -- so it fails loudly on such a table. Add it by hand in that case:
34
49
  -- ALTER TABLE people ADD COLUMN folders TEXT NOT NULL DEFAULT '';
50
+ -- The key is (accountId, ownerId), not ownerId alone. On the shared store two
51
+ -- clients may each enrol a person called `rob`, and a bare ownerId key would
52
+ -- reject the second enrolment outright. Worse, the enrolment upsert would treat
53
+ -- it as a rotation and overwrite the first client's person: their passcode,
54
+ -- their folder grant, and the owner of their files.
35
55
  CREATE TABLE IF NOT EXISTS people (
36
- ownerId TEXT PRIMARY KEY,
56
+ ownerId TEXT NOT NULL,
37
57
  name TEXT NOT NULL,
38
58
  salt TEXT NOT NULL,
39
59
  hash TEXT NOT NULL,
40
60
  createdAt TEXT NOT NULL,
41
61
  accountId TEXT NOT NULL DEFAULT '',
42
- folders TEXT NOT NULL DEFAULT ''
62
+ folders TEXT NOT NULL DEFAULT '',
63
+ PRIMARY KEY (accountId, ownerId)
43
64
  );
44
65
 
45
66
  -- `pcCheck` is a short digest of the person's passcode hash at mint time, and
@@ -48,9 +69,16 @@ CREATE TABLE IF NOT EXISTS people (
48
69
  -- every session minted against the old one stops resolving immediately, with
49
70
  -- no cleanup step for an operator to remember and no 12-hour window to wait
50
71
  -- out. A leaked passcode is exactly the case where a procedural remedy fails.
72
+ --
73
+ -- `accountId` records which portal minted the session. `sessionId` is a global
74
+ -- UUID and is safe as the key on its own, but the resolve joins `people` and
75
+ -- that join must match on both columns: keyed on ownerId alone it can pair a
76
+ -- session with a same-named person belonging to a different client, and hand
77
+ -- back that client's folder grant.
51
78
  CREATE TABLE IF NOT EXISTS sessions (
52
79
  sessionId TEXT PRIMARY KEY,
53
80
  ownerId TEXT NOT NULL,
81
+ accountId TEXT NOT NULL DEFAULT '',
54
82
  pcCheck TEXT NOT NULL,
55
83
  expiresAt INTEGER NOT NULL,
56
84
  createdAt TEXT NOT NULL
@@ -92,22 +120,34 @@ CREATE INDEX IF NOT EXISTS sessions_owner ON sessions (ownerId);
92
120
  -- ALTER TABLE manifest ADD COLUMN devicePath TEXT NOT NULL DEFAULT '';
93
121
  -- ALTER TABLE manifest ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0;
94
122
  -- Their absence is loud: upload names targetPath in its INSERT.
123
+ -- `accountId` is the tenant the row belongs to, and `objectKey` is unique only
124
+ -- within it. A bare `objectKey UNIQUE` is not merely too broad on a shared
125
+ -- store, it is exploitable: the upload upsert conflicts on that key, so one
126
+ -- client uploading at another client's key would take over the existing row,
127
+ -- reset its ingested flag, and redirect the file. The pull loop also filters on
128
+ -- this column, which is what stops one account's sweep collecting another's
129
+ -- pending uploads into its own folder tree.
95
130
  CREATE TABLE IF NOT EXISTS manifest (
96
131
  id INTEGER PRIMARY KEY AUTOINCREMENT,
97
132
  fileId TEXT UNIQUE NOT NULL,
133
+ accountId TEXT NOT NULL DEFAULT '',
98
134
  ownerId TEXT NOT NULL,
99
135
  filename TEXT NOT NULL,
100
- objectKey TEXT UNIQUE NOT NULL,
136
+ objectKey TEXT NOT NULL,
101
137
  size INTEGER NOT NULL,
102
138
  uploadedAt TEXT NOT NULL,
103
139
  ingested INTEGER NOT NULL DEFAULT 0,
104
140
  targetPath TEXT NOT NULL DEFAULT '',
105
141
  devicePath TEXT NOT NULL DEFAULT '',
106
- deleted INTEGER NOT NULL DEFAULT 0
142
+ deleted INTEGER NOT NULL DEFAULT 0,
143
+ UNIQUE (accountId, objectKey)
107
144
  );
108
145
 
109
146
  CREATE INDEX IF NOT EXISTS manifest_uningested ON manifest (ingested);
110
147
  CREATE INDEX IF NOT EXISTS manifest_owner ON manifest (ownerId);
148
+ -- The pull and the audit both filter by account on the shared store, so the
149
+ -- discriminator carries its own index rather than riding the owner one.
150
+ CREATE INDEX IF NOT EXISTS manifest_account ON manifest (accountId);
111
151
  -- The pull selects `ingested = 0 OR deleted = 1`, so the withdrawal half needs
112
152
  -- its own index or a withdrawal costs a full scan every sixty seconds.
113
153
  CREATE INDEX IF NOT EXISTS manifest_deleted ON manifest (deleted);
@@ -1,32 +1,41 @@
1
1
  // The isolation rule.
2
2
  //
3
- // Every file op authorizes owner-vs-key here BEFORE any R2 call. The
4
- // per-person key prefix is a consequence of this check, never the check
5
- // itself: a caller could otherwise reach another person's files by naming
6
- // them, and prefix convention alone would not stop it.
3
+ // Every file op authorizes tenant-and-owner-vs-key here BEFORE any R2 call. The
4
+ // key prefix is a consequence of this check, never the check itself: a caller
5
+ // could otherwise reach another person's files by naming them, and prefix
6
+ // convention alone would not stop it.
7
+ //
8
+ // There are TWO segments to check, not one (Task 2099). Every portal binds the
9
+ // same shared bucket, so the account segment is the only thing separating one
10
+ // client's objects from another's. Checking the owner segment alone would leave
11
+ // `<other-account>/alice/x.pdf` authorized for this portal's alice, and the
12
+ // delete, download and multipart-complete paths all take the key straight from
13
+ // the client, so this is verified on every call rather than assumed from how
14
+ // the key was built.
7
15
 
8
16
  const SEP = '/'
9
17
 
10
- // The whole gate rests on an ownerId that cannot contain the separator. If two
11
- // people were ever enrolled as 'alice' and 'alice/2024', then
18
+ // The whole gate rests on ids that cannot contain the separator. If two people
19
+ // were ever enrolled as 'alice' and 'alice/2024', then
12
20
  // 'alice/2024/invoice.pdf' starts with 'alice/' and alice would read the other
13
- // person's files. Enrolment is an agent writing rows, so this invariant cannot
14
- // live only in the skill's prose it is enforced here, where the decision is
15
- // actually made, and it fails closed.
16
- const OWNER_ID = /^[a-z0-9-]+$/
21
+ // person's files; the same argument applies to the account segment. Enrolment
22
+ // and assembly are agents writing values, so this invariant cannot live only in
23
+ // the skill's prose. It is enforced here, where the decision is actually made,
24
+ // and it fails closed.
25
+ const SAFE_ID = /^[a-z0-9-]+$/
17
26
 
18
- export function ownerPrefix(ownerId: string): string {
19
- return `${ownerId}${SEP}`
27
+ export function ownerPrefix(accountId: string, ownerId: string): string {
28
+ return `${accountId}${SEP}${ownerId}${SEP}`
20
29
  }
21
30
 
22
- export function authorizeKey(ownerId: string, key: string): boolean {
23
- if (!ownerId || !key) return false
24
- if (!OWNER_ID.test(ownerId)) return false
31
+ export function authorizeKey(accountId: string, ownerId: string, key: string): boolean {
32
+ if (!accountId || !ownerId || !key) return false
33
+ if (!SAFE_ID.test(accountId) || !SAFE_ID.test(ownerId)) return false
25
34
  // Reject traversal and absolute forms outright rather than normalising them:
26
35
  // a normaliser is one more thing that can be wrong, and no legitimate key
27
36
  // contains these.
28
37
  if (key.includes('..') || key.startsWith(SEP)) return false
29
- // startsWith(ownerId) alone would authorize 'alice-evil/...' for 'alice'.
30
- // The separator is part of the rule.
31
- return key.startsWith(ownerPrefix(ownerId))
38
+ // startsWith on the bare ids would authorize 'acct-a-evil/...' for 'acct-a'
39
+ // and 'alice-evil/...' for 'alice'. Both separators are part of the rule.
40
+ return key.startsWith(ownerPrefix(accountId, ownerId))
32
41
  }
@@ -19,9 +19,16 @@ export const RATE_MAX_ATTEMPTS = 5
19
19
  * unauthenticated requests naming a person lock that person out of their own
20
20
  * portal for the window, from anywhere, repeatably. Keyed on owner+IP, an
21
21
  * attacker throttles only themselves. The cost is that a distributed guesser
22
- * gets RATE_MAX_ATTEMPTS per address, which the passcodes' entropy carries. */
23
- export function rateScope(ownerId: string, clientIp: string): string {
24
- return `${ownerId}|${clientIp}`
22
+ * gets RATE_MAX_ATTEMPTS per address, which the passcodes' entropy carries.
23
+ *
24
+ * The account leads the key because the `auth_attempts` table is shared by
25
+ * every portal (Task 2099). Two clients who each enrol a person of the same
26
+ * name would otherwise share one lockout counter, so traffic against one
27
+ * client's portal could lock a different client's person out of theirs. This
28
+ * is why the table itself needs no accountId column: the tenant is already
29
+ * inside `scope`, and the unique index is on (scope, windowStart). */
30
+ export function rateScope(accountId: string, ownerId: string, clientIp: string): string {
31
+ return `${accountId}|${ownerId}|${clientIp}`
25
32
  }
26
33
 
27
34
  /** Clear a scope's counter. Called on a successful sign-in, so a person doing
@@ -31,21 +31,31 @@ export function passcodeCheck(hash: string): string {
31
31
  export async function mintSession(
32
32
  db: D1Database,
33
33
  ownerId: string,
34
+ accountId: string,
34
35
  sessionId: string,
35
36
  nowMs: number,
36
37
  pcCheck: string,
37
38
  ): Promise<string> {
38
39
  await db
39
40
  .prepare(
40
- 'INSERT INTO sessions (sessionId, ownerId, pcCheck, expiresAt, createdAt) VALUES (?, ?, ?, ?, ?)',
41
+ 'INSERT INTO sessions (sessionId, ownerId, accountId, pcCheck, expiresAt, createdAt) VALUES (?, ?, ?, ?, ?, ?)',
42
+ )
43
+ .bind(
44
+ sessionId,
45
+ ownerId,
46
+ accountId,
47
+ pcCheck,
48
+ nowMs + SESSION_TTL_MS,
49
+ new Date(nowMs).toISOString(),
41
50
  )
42
- .bind(sessionId, ownerId, pcCheck, nowMs + SESSION_TTL_MS, new Date(nowMs).toISOString())
43
51
  .run()
44
52
  // Opportunistic GC: expired rows are dead weight and nothing else prunes
45
- // them. Scoped to this owner so it stays a small, indexed delete.
53
+ // them. Scoped to this owner so it stays a small, indexed delete, and to this
54
+ // account because the store is shared: two portals may hold the same owner
55
+ // id, and an owner-only delete would prune the other portal's rows.
46
56
  await db
47
- .prepare('DELETE FROM sessions WHERE ownerId = ? AND expiresAt <= ?')
48
- .bind(ownerId, nowMs)
57
+ .prepare('DELETE FROM sessions WHERE ownerId = ? AND accountId = ? AND expiresAt <= ?')
58
+ .bind(ownerId, accountId, nowMs)
49
59
  .run()
50
60
  return sessionId
51
61
  }
@@ -53,6 +63,7 @@ export async function mintSession(
53
63
  export async function resolveSession(
54
64
  db: D1Database,
55
65
  sessionId: string,
66
+ accountId: string,
56
67
  nowMs: number,
57
68
  ): Promise<{ ownerId: string; accountId: string; folders: string[] } | null> {
58
69
  if (!sessionId) return null
@@ -60,18 +71,23 @@ export async function resolveSession(
60
71
  .prepare(
61
72
  `SELECT s.ownerId AS ownerId, p.accountId AS accountId, p.folders AS folders
62
73
  FROM sessions s
63
- JOIN people p ON p.ownerId = s.ownerId
74
+ JOIN people p ON p.ownerId = s.ownerId AND p.accountId = s.accountId
64
75
  WHERE s.sessionId = ?
76
+ AND s.accountId = ?
65
77
  AND s.expiresAt > ?
66
78
  AND s.pcCheck = substr(p.hash, 1, 16)`,
67
79
  )
68
- .bind(sessionId, nowMs)
80
+ .bind(sessionId, accountId, nowMs)
69
81
  .first<{ ownerId: string; accountId: string; folders: string }>()
70
82
  if (!row || typeof row.ownerId !== 'string') return null
71
- // A `people` row predating the accountId column resolves it as absent. Treat
72
- // that as "no folders", never as "all folders": the empty string reaches the
73
- // listing, which returns nothing rather than falling back to some default.
74
- const accountId = typeof row.accountId === 'string' ? row.accountId : ''
83
+ // The join matches on BOTH columns and the lookup is already scoped to this
84
+ // portal's own account, which is what stops a same-named person in another
85
+ // tenant resolving here. A row that still disagrees is a contradiction, and
86
+ // on a shared store the safe reading of a contradiction is nothing at all.
87
+ // This also fails closed on a legacy row carrying no accountId, rather than
88
+ // treating an absent tenant as a match.
89
+ const resolved = typeof row.accountId === 'string' ? row.accountId : ''
90
+ if (!resolved || resolved !== accountId) return null
75
91
  // `folders` is a comma-separated allowlist of top-level names. Absent or empty
76
92
  // means no grant, which the two client surfaces read as no filter (full set).
77
93
  const folders =
@@ -76,16 +76,25 @@ export async function checkTarget(
76
76
  }
77
77
 
78
78
  /**
79
- * `ownerId/targetPath/filename`, collapsing to `ownerId/filename` when
80
- * untargeted.
79
+ * `accountId/ownerId/targetPath/filename`, collapsing to
80
+ * `accountId/ownerId/filename` when untargeted.
81
81
  *
82
- * The target is IN the key because `objectKey` is UNIQUE. With a flat key the
83
- * same filename dropped into two folders would collide: the upsert would
84
- * overwrite the first object, reset its ingested flag, and the first file would
85
- * never reach the device at all.
82
+ * The account leads the key because the bucket is shared by every portal (Task
83
+ * 2099): without it, two clients who each enrol a person of the same name would
84
+ * write to the same object path.
85
+ *
86
+ * The target is IN the key because `objectKey` is unique per account. With a
87
+ * flat key the same filename dropped into two folders would collide: the upsert
88
+ * would overwrite the first object, reset its ingested flag, and the first file
89
+ * would never reach the device at all.
86
90
  */
87
- export function uploadKey(ownerId: string, targetPath: string, filename: string): string {
91
+ export function uploadKey(
92
+ accountId: string,
93
+ ownerId: string,
94
+ targetPath: string,
95
+ filename: string,
96
+ ): string {
88
97
  return targetPath
89
- ? `${ownerPrefix(ownerId)}${targetPath}/${filename}`
90
- : `${ownerPrefix(ownerId)}${filename}`
98
+ ? `${ownerPrefix(accountId, ownerId)}${targetPath}/${filename}`
99
+ : `${ownerPrefix(accountId, ownerId)}${filename}`
91
100
  }
@@ -57,6 +57,22 @@ export interface R2Bucket {
57
57
  export interface PortalEnv {
58
58
  DB: D1Database
59
59
  BUCKET: R2Bucket
60
+ /**
61
+ * This portal's own account id, stamped into wrangler.toml [vars] at assemble
62
+ * time by bin/portal-assemble.mjs.
63
+ *
64
+ * It is the tenant scope for every query and every object key. DB and BUCKET
65
+ * above are the ONE shared store that backs every portal (Task 2099), so this
66
+ * value is the only thing separating this client's rows and objects from
67
+ * another client's.
68
+ *
69
+ * Deliberately NOT taken from the session or from `people.accountId`: the
70
+ * people join is by ownerId, two clients may each enrol a person of the same
71
+ * name, and a tenant scope derived from the request would then resolve to
72
+ * whichever row happened to match. It is baked in at deploy time precisely so
73
+ * it cannot be influenced by a request.
74
+ */
75
+ PORTAL_ACCOUNT_ID: string
60
76
  /**
61
77
  * Shared with the install; signs download links for device-held files. A
62
78
  * Pages SECRET, never a var in wrangler.toml, and never sent to the client.
@@ -53,15 +53,20 @@ export async function processAuth(
53
53
  // owner: keyed on the owner alone, the counter is a weapon — six requests
54
54
  // naming a person lock that person out of their own portal for the window,
55
55
  // from anywhere, unauthenticated.
56
- const scope = rateScope(ownerId, clientIp)
56
+ const scope = rateScope(env.PORTAL_ACCOUNT_ID, ownerId, clientIp)
57
57
  const rate = await checkAndRecordAttempt(env.DB, scope, nowMs)
58
58
  if (!rate.allowed) {
59
59
  log(`[data-portal] op=auth owner=${q(ownerId)} result=rate-limited attempts=${rate.count}`)
60
60
  return { status: 429, payload: { ok: false, error: 'too many attempts' } }
61
61
  }
62
62
 
63
- const person = await env.DB.prepare('SELECT ownerId, salt, hash FROM people WHERE ownerId = ?')
64
- .bind(ownerId)
63
+ // Scoped to this portal's own account: the people table is shared by every
64
+ // portal, so an ownerId-only lookup would find a same-named person belonging
65
+ // to another client and check the passcode against THEIR hash.
66
+ const person = await env.DB.prepare(
67
+ 'SELECT ownerId, salt, hash FROM people WHERE ownerId = ? AND accountId = ?',
68
+ )
69
+ .bind(ownerId, env.PORTAL_ACCOUNT_ID)
65
70
  .first<{ ownerId: string; salt: string; hash: string }>()
66
71
 
67
72
  if (!person) {
@@ -82,7 +87,14 @@ export async function processAuth(
82
87
  await clearAttempts(env.DB, scope)
83
88
 
84
89
  const sessionId = newId()
85
- await mintSession(env.DB, ownerId, sessionId, nowMs, passcodeCheck(person.hash))
90
+ await mintSession(
91
+ env.DB,
92
+ ownerId,
93
+ env.PORTAL_ACCOUNT_ID,
94
+ sessionId,
95
+ nowMs,
96
+ passcodeCheck(person.hash),
97
+ )
86
98
  log(`[data-portal] op=auth owner=${q(ownerId)} result=ok`)
87
99
  return { status: 200, payload: { ok: true }, sessionId }
88
100
  }
@@ -11,21 +11,27 @@ export async function processDelete(
11
11
  now: () => number,
12
12
  ): Promise<Handler> {
13
13
  const startedMs = now()
14
- const session = await resolveSession(env.DB, sessionId, startedMs)
14
+ const session = await resolveSession(env.DB, sessionId, env.PORTAL_ACCOUNT_ID, startedMs)
15
15
  if (!session) {
16
16
  log('[data-portal] op=delete owner=none result=denied')
17
17
  return { status: 401, payload: { ok: false, error: 'denied' } }
18
18
  }
19
- if (!authorizeKey(session.ownerId, key)) {
19
+ // The key arrives from the client, so the account segment is checked here and
20
+ // never inferred: the bucket is shared, and without it a caller could name
21
+ // another tenant's object outright.
22
+ if (!authorizeKey(env.PORTAL_ACCOUNT_ID, session.ownerId, key)) {
20
23
  log(`[data-portal] op=delete owner=${q(session.ownerId)} result=denied reason=not-owner`)
21
24
  return { status: 403, payload: { ok: false, error: 'denied' } }
22
25
  }
23
26
  const owner = q(session.ownerId)
24
27
 
25
28
  // What the client is asking to remove decides HOW it is removed, so the row
26
- // is read before anything is written (Task 1910).
27
- const row = await env.DB.prepare('SELECT devicePath FROM manifest WHERE objectKey = ?')
28
- .bind(key)
29
+ // is read before anything is written (Task 1910). Every manifest statement
30
+ // below carries the account: objectKey is unique per account, not globally.
31
+ const row = await env.DB.prepare(
32
+ 'SELECT devicePath FROM manifest WHERE objectKey = ? AND accountId = ?',
33
+ )
34
+ .bind(key, env.PORTAL_ACCOUNT_ID)
29
35
  .first<{ devicePath: string }>()
30
36
  if (!row) {
31
37
  log(`[data-portal] op=delete owner=${owner} key=${q(key)} result=absent`)
@@ -43,7 +49,11 @@ export async function processDelete(
43
49
  // minute and saying otherwise would be a lie the next refresh exposes.
44
50
  if (row.devicePath) {
45
51
  try {
46
- await env.DB.prepare('UPDATE manifest SET deleted = 1 WHERE objectKey = ?').bind(key).run()
52
+ await env.DB.prepare(
53
+ 'UPDATE manifest SET deleted = 1 WHERE objectKey = ? AND accountId = ?',
54
+ )
55
+ .bind(key, env.PORTAL_ACCOUNT_ID)
56
+ .run()
47
57
  } catch (err) {
48
58
  log(
49
59
  `[data-portal] op=withdraw owner=${owner} key=${q(key)} ` +
@@ -53,8 +63,10 @@ export async function processDelete(
53
63
  }
54
64
  // Re-read rather than assume the UPDATE landed: an unmarked row is a
55
65
  // withdrawal the pull will never see, and nothing else would report it.
56
- const marked = await env.DB.prepare('SELECT deleted FROM manifest WHERE objectKey = ?')
57
- .bind(key)
66
+ const marked = await env.DB.prepare(
67
+ 'SELECT deleted FROM manifest WHERE objectKey = ? AND accountId = ?',
68
+ )
69
+ .bind(key, env.PORTAL_ACCOUNT_ID)
58
70
  .first<{ deleted: number }>()
59
71
  const ok = marked?.deleted === 1
60
72
  log(
@@ -84,7 +96,9 @@ export async function processDelete(
84
96
  }
85
97
 
86
98
  try {
87
- await env.DB.prepare('DELETE FROM manifest WHERE objectKey = ?').bind(key).run()
99
+ await env.DB.prepare('DELETE FROM manifest WHERE objectKey = ? AND accountId = ?')
100
+ .bind(key, env.PORTAL_ACCOUNT_ID)
101
+ .run()
88
102
  log(`[data-portal] op=manifest-delete owner=${owner} key=${q(key)} result=ok`)
89
103
  } catch (err) {
90
104
  log(
@@ -96,8 +110,9 @@ export async function processDelete(
96
110
  // The verified post-condition: both sides re-read, not "we called delete".
97
111
  const objectPresent = (await env.BUCKET.head(key)) !== null
98
112
  const rowPresent =
99
- (await env.DB.prepare('SELECT fileId FROM manifest WHERE objectKey = ?').bind(key).first()) !==
100
- null
113
+ (await env.DB.prepare('SELECT fileId FROM manifest WHERE objectKey = ? AND accountId = ?')
114
+ .bind(key, env.PORTAL_ACCOUNT_ID)
115
+ .first()) !== null
101
116
  // Derived, never hardcoded: a failed delete printing result=ok next to
102
117
  // objectPresent=true is a self-contradicting line.
103
118
  const result = !objectPresent && !rowPresent ? 'ok' : 'incomplete'
@@ -18,21 +18,25 @@ export async function processDownload(
18
18
  log: Logger,
19
19
  nowMs: number,
20
20
  ): Promise<DownloadResult> {
21
- const session = await resolveSession(env.DB, sessionId, nowMs)
21
+ const session = await resolveSession(env.DB, sessionId, env.PORTAL_ACCOUNT_ID, nowMs)
22
22
  if (!session) {
23
23
  log('[data-portal] op=download owner=none result=denied')
24
24
  return { status: 401, payload: { ok: false, error: 'denied' } }
25
25
  }
26
- // Authorize BEFORE any R2 call, so a denied caller cannot cause a fetch.
27
- if (!authorizeKey(session.ownerId, key)) {
26
+ // Authorize BEFORE any R2 call, so a denied caller cannot cause a fetch. The
27
+ // key comes from the client and the bucket is shared, so the account segment
28
+ // is part of what is checked, never inferred from how the key was built.
29
+ if (!authorizeKey(env.PORTAL_ACCOUNT_ID, session.ownerId, key)) {
28
30
  log(`[data-portal] op=download owner=${q(session.ownerId)} result=denied reason=not-owner`)
29
31
  return { status: 403, payload: { ok: false, error: 'denied' } }
30
32
  }
31
33
  // Where the bytes are decides how they are served (Task 1910). A moved row's
32
34
  // R2 object has been deleted, so reading it here would 404 the client's own
33
35
  // upload the moment the device collected it.
34
- const row = await env.DB.prepare('SELECT devicePath FROM manifest WHERE objectKey = ?')
35
- .bind(key)
36
+ const row = await env.DB.prepare(
37
+ 'SELECT devicePath FROM manifest WHERE objectKey = ? AND accountId = ?',
38
+ )
39
+ .bind(key, env.PORTAL_ACCOUNT_ID)
36
40
  .first<{ devicePath: string }>()
37
41
  if (!row) {
38
42
  log(`[data-portal] op=download owner=${q(session.ownerId)} result=absent`)
@@ -53,7 +57,7 @@ export async function processDownload(
53
57
  const url = await buildOwnUploadUrl(
54
58
  origin,
55
59
  secret,
56
- session.accountId,
60
+ env.PORTAL_ACCOUNT_ID,
57
61
  session.ownerId,
58
62
  row.devicePath,
59
63
  expiresAt,
@@ -106,12 +110,12 @@ export async function processIndexedDownload(
106
110
  nowMs: number,
107
111
  fetchFn: HeadFetch,
108
112
  ): Promise<IndexedDownloadResult> {
109
- const session = await resolveSession(env.DB, sessionId, nowMs)
113
+ const session = await resolveSession(env.DB, sessionId, env.PORTAL_ACCOUNT_ID, nowMs)
110
114
  if (!session) {
111
115
  log('[data-portal] op=fetch-through owner=none result=denied')
112
116
  return { status: 401, payload: { ok: false, error: 'denied' } }
113
117
  }
114
- if (!session.accountId) {
118
+ if (!env.PORTAL_ACCOUNT_ID) {
115
119
  log(`[data-portal] op=fetch-through owner=${q(session.ownerId)} result=no-account`)
116
120
  return { status: 404, payload: { ok: false, error: 'not found' } }
117
121
  }
@@ -135,7 +139,7 @@ export async function processIndexedDownload(
135
139
  ON s.accountId = d.accountId AND s.currentGeneration = d.generation
136
140
  WHERE d.accountId = ? AND d.relPath = ?`,
137
141
  )
138
- .bind(session.accountId, relPath)
142
+ .bind(env.PORTAL_ACCOUNT_ID, relPath)
139
143
  .first<{ relPath: string; isDir: number }>()
140
144
  if (!row) {
141
145
  log(
@@ -165,7 +169,7 @@ export async function processIndexedDownload(
165
169
  }
166
170
 
167
171
  const expiresAt = nowMs + SIGNED_TTL_MS
168
- const url = await buildSignedUrl(origin, secret, session.accountId, relPath, expiresAt)
172
+ const url = await buildSignedUrl(origin, secret, env.PORTAL_ACCOUNT_ID, relPath, expiresAt)
169
173
 
170
174
  const started = Date.now()
171
175
  try {
@@ -114,31 +114,35 @@ export async function processFiles(
114
114
  nowMs: number,
115
115
  prefix = '',
116
116
  ): Promise<Handler> {
117
- const session = await resolveSession(env.DB, sessionId, nowMs)
117
+ const session = await resolveSession(env.DB, sessionId, env.PORTAL_ACCOUNT_ID, nowMs)
118
118
  if (!session) {
119
119
  log('[data-portal] op=list owner=none result=denied')
120
120
  return { status: 401, payload: { ok: false, error: 'denied' } }
121
121
  }
122
- // Scoped by ownerId, not by key prefix: the owner is the authority.
122
+ // Scoped by account AND owner, not by key prefix: the owner is the authority
123
+ // for which files, the account for whose. The manifest is shared by every
124
+ // portal, so an owner-only filter would list a same-named person's uploads
125
+ // from another client outright.
123
126
  const rows = await env.DB.prepare(
124
127
  'SELECT fileId, filename, objectKey, size, uploadedAt, ingested, targetPath, devicePath, deleted ' +
125
- 'FROM manifest WHERE ownerId = ? ORDER BY uploadedAt DESC',
128
+ 'FROM manifest WHERE accountId = ? AND ownerId = ? ORDER BY uploadedAt DESC',
126
129
  )
127
- .bind(session.ownerId)
130
+ .bind(env.PORTAL_ACCOUNT_ID, session.ownerId)
128
131
  .all<Record<string, unknown>>()
129
132
  const allFiles = rows.results ?? []
130
133
 
131
134
  // The account's published folder index, scoped by accountId and read through
132
- // the generation pointer so a push in flight is never half-visible. No
133
- // account bound means no folders: an enrolment predating that column lands
134
- // here, and the empty `account=` field in the log line is what says so.
135
+ // the generation pointer so a push in flight is never half-visible. Bound to
136
+ // the portal's own account rather than the session's: resolveSession already
137
+ // requires the two to agree, and binding the baked-in value keeps the tenant
138
+ // scope independent of anything a request can influence.
135
139
  let entries: DirEntry[] = []
136
140
  const grant = session.folders
137
141
  // The tops the client can actually browse. Built from the SAME grant-filtered
138
142
  // rows the tree is built from, so the uploads panel and the tree can never
139
143
  // disagree about what is reachable.
140
144
  const visibleTops = new Set<string>()
141
- if (session.accountId) {
145
+ if (env.PORTAL_ACCOUNT_ID) {
142
146
  const dir = await env.DB.prepare(
143
147
  `SELECT d.relPath AS relPath, d.sizeBytes AS sizeBytes, d.modifiedAt AS modifiedAt,
144
148
  d.isDir AS isDir
@@ -147,7 +151,7 @@ export async function processFiles(
147
151
  ON s.accountId = d.accountId AND s.currentGeneration = d.generation
148
152
  WHERE d.accountId = ?`,
149
153
  )
150
- .bind(session.accountId)
154
+ .bind(env.PORTAL_ACCOUNT_ID)
151
155
  .all<DirRow>()
152
156
  // The grant narrows below the account's exposed set: keep only rows the
153
157
  // grant admits. An empty grant admits everything, which is the full set and