@rubytech/create-maxy-code 0.1.562 → 0.1.563

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 (185) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +19 -11
  3. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +6 -0
  4. package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +19 -5
  5. package/payload/platform/plugins/cloudflare/mcp/__tests__/authorize.test.ts +6 -0
  6. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +77 -6
  7. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-session-grant.test.ts +39 -0
  8. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-visit-status-write.test.ts +176 -0
  9. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-visits-scope.test.ts +112 -0
  10. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +23 -0
  11. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +94 -0
  12. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/session.ts +31 -4
  13. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/visit-status.ts +136 -0
  14. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/visits.ts +93 -0
  15. package/payload/platform/plugins/docs/references/deployment.md +14 -8
  16. package/payload/platform/plugins/docs/references/platform.md +4 -2
  17. package/payload/platform/templates/agents/public/IDENTITY.md +3 -3
  18. package/payload/server/maxy-edge.js +302 -33
  19. package/payload/server/public/activity.html +5 -5
  20. package/payload/server/public/agents.html +4 -4
  21. package/payload/server/public/assets/{AdminLoginScreens-DLogIsJT.js → AdminLoginScreens-C6e5jf0S.js} +1 -1
  22. package/payload/server/public/assets/AdminLoginScreens-C6e5jf0S.js.br +0 -0
  23. package/payload/server/public/assets/AdminLoginScreens-C6e5jf0S.js.gz +0 -0
  24. package/payload/server/public/assets/AdminShell-DStA0lfm.js +3 -0
  25. package/payload/server/public/assets/AdminShell-DStA0lfm.js.br +0 -0
  26. package/payload/server/public/assets/AdminShell-DStA0lfm.js.gz +0 -0
  27. package/payload/server/public/assets/{activity-AVJSFESV.js → activity--yyBcNfB.js} +1 -1
  28. package/payload/server/public/assets/activity--yyBcNfB.js.br +0 -0
  29. package/payload/server/public/assets/activity--yyBcNfB.js.gz +0 -0
  30. package/payload/server/public/assets/{admin-eFgbld42.js → admin-Dr6Jwe7W.js} +1 -1
  31. package/payload/server/public/assets/admin-Dr6Jwe7W.js.br +0 -0
  32. package/payload/server/public/assets/admin-Dr6Jwe7W.js.gz +0 -0
  33. package/payload/server/public/assets/{agents-CoWLZMf7.js → agents-odyVOb5E.js} +1 -1
  34. package/payload/server/public/assets/agents-odyVOb5E.js.br +0 -0
  35. package/payload/server/public/assets/agents-odyVOb5E.js.gz +0 -0
  36. package/payload/server/public/assets/{browser-C6K66_fw.js → browser-D1K_MoHt.js} +1 -1
  37. package/payload/server/public/assets/browser-D1K_MoHt.js.br +0 -0
  38. package/payload/server/public/assets/browser-D1K_MoHt.js.gz +0 -0
  39. package/payload/server/public/assets/{calendar-Dnw_cP4b.js → calendar-jqh9U7Ic.js} +1 -1
  40. package/payload/server/public/assets/calendar-jqh9U7Ic.js.br +0 -0
  41. package/payload/server/public/assets/calendar-jqh9U7Ic.js.gz +0 -0
  42. package/payload/server/public/assets/chat-QQHBdVor.js +1 -0
  43. package/payload/server/public/assets/chat-QQHBdVor.js.br +0 -0
  44. package/payload/server/public/assets/chat-QQHBdVor.js.gz +0 -0
  45. package/payload/server/public/assets/chevron-left-DwTXgKwr.js +1 -0
  46. package/payload/server/public/assets/clock-DOmh9iKX.js +1 -0
  47. package/payload/server/public/assets/clock-DOmh9iKX.js.br +0 -0
  48. package/payload/server/public/assets/{copy-kuNM0Etw.js → copy-DkuHwkaB.js} +1 -1
  49. package/payload/server/public/assets/copy-DkuHwkaB.js.br +0 -0
  50. package/payload/server/public/assets/copy-DkuHwkaB.js.gz +0 -0
  51. package/payload/server/public/assets/data-DYmTNqrb.js +1 -0
  52. package/payload/server/public/assets/data-DYmTNqrb.js.br +0 -0
  53. package/payload/server/public/assets/data-DYmTNqrb.js.gz +0 -0
  54. package/payload/server/public/assets/{file-text-BWkobrcx.js → file-text-C6o_NRjj.js} +1 -1
  55. package/payload/server/public/assets/file-text-C6o_NRjj.js.br +0 -0
  56. package/payload/server/public/assets/file-text-C6o_NRjj.js.gz +0 -0
  57. package/payload/server/public/assets/{graph-BGjTL5Vz.js → graph-BN30v5hJ.js} +1 -1
  58. package/payload/server/public/assets/graph-BN30v5hJ.js.br +0 -0
  59. package/payload/server/public/assets/graph-BN30v5hJ.js.gz +0 -0
  60. package/payload/server/public/assets/{graph-labels-Dvc5Kw_r.js → graph-labels-DHFyNXQt.js} +1 -1
  61. package/payload/server/public/assets/graph-labels-DHFyNXQt.js.br +0 -0
  62. package/payload/server/public/assets/graph-labels-DHFyNXQt.js.gz +0 -0
  63. package/payload/server/public/assets/{operator-CzBGv_he.js → operator-DwFYA4Fp.js} +1 -1
  64. package/payload/server/public/assets/operator-DwFYA4Fp.js.br +0 -0
  65. package/payload/server/public/assets/operator-DwFYA4Fp.js.gz +0 -0
  66. package/payload/server/public/assets/{page-DaySqamH.js → page-BALVRssF.js} +1 -1
  67. package/payload/server/public/assets/page-BALVRssF.js.br +0 -0
  68. package/payload/server/public/assets/page-BALVRssF.js.gz +0 -0
  69. package/payload/server/public/assets/{page-BIY4Ku8Q.js → page-CEWIP3VF.js} +1 -1
  70. package/payload/server/public/assets/page-CEWIP3VF.js.br +0 -0
  71. package/payload/server/public/assets/page-CEWIP3VF.js.gz +0 -0
  72. package/payload/server/public/assets/play-Bg-BY_oj.js +1 -0
  73. package/payload/server/public/assets/play-Bg-BY_oj.js.br +0 -0
  74. package/payload/server/public/assets/play-Bg-BY_oj.js.gz +0 -0
  75. package/payload/server/public/assets/{public-pwDSvhBv.js → public-Ceb0n7Km.js} +1 -1
  76. package/payload/server/public/assets/public-Ceb0n7Km.js.br +0 -0
  77. package/payload/server/public/assets/public-Ceb0n7Km.js.gz +0 -0
  78. package/payload/server/public/assets/{rotate-ccw-B6s7tkEt.js → rotate-ccw-CDYdx91V.js} +1 -1
  79. package/payload/server/public/assets/rotate-ccw-CDYdx91V.js.gz +0 -0
  80. package/payload/server/public/assets/{routines-CFlyRdYg.js → routines-DwNTih0y.js} +1 -1
  81. package/payload/server/public/assets/routines-DwNTih0y.js.br +0 -0
  82. package/payload/server/public/assets/routines-DwNTih0y.js.gz +0 -0
  83. package/payload/server/public/assets/{skills-C45kfL_Q.js → skills-TIDCdOLQ.js} +1 -1
  84. package/payload/server/public/assets/skills-TIDCdOLQ.js.br +0 -0
  85. package/payload/server/public/assets/skills-TIDCdOLQ.js.gz +0 -0
  86. package/payload/server/public/assets/{tasks-BwoCTCu5.js → tasks-eTXvaWnd.js} +1 -1
  87. package/payload/server/public/assets/tasks-eTXvaWnd.js.br +0 -0
  88. package/payload/server/public/assets/tasks-eTXvaWnd.js.gz +0 -0
  89. package/payload/server/public/assets/{triangle-alert-B3bthSzx.js → triangle-alert-JwLSrj17.js} +1 -1
  90. package/payload/server/public/assets/triangle-alert-JwLSrj17.js.br +0 -0
  91. package/payload/server/public/assets/triangle-alert-JwLSrj17.js.gz +0 -0
  92. package/payload/server/public/assets/{useCopyFeedback-BzJAkFEb.js → useCopyFeedback-DjTkNw2p.js} +1 -1
  93. package/payload/server/public/assets/useCopyFeedback-DjTkNw2p.js.br +0 -0
  94. package/payload/server/public/assets/useCopyFeedback-DjTkNw2p.js.gz +0 -0
  95. package/payload/server/public/assets/useMediaQuery-BsBzbWaf.css +1 -0
  96. package/payload/server/public/assets/useMediaQuery-BsBzbWaf.css.br +0 -0
  97. package/payload/server/public/assets/useMediaQuery-BsBzbWaf.css.gz +0 -0
  98. package/payload/server/public/assets/useMediaQuery-D8VDA0RN.js +14 -0
  99. package/payload/server/public/assets/useMediaQuery-D8VDA0RN.js.br +0 -0
  100. package/payload/server/public/assets/useMediaQuery-D8VDA0RN.js.gz +0 -0
  101. package/payload/server/public/assets/{useVoiceRecorder-_qI_BAOo.js → useVoiceRecorder-B8vxObZG.js} +1 -1
  102. package/payload/server/public/assets/useVoiceRecorder-B8vxObZG.js.br +0 -0
  103. package/payload/server/public/assets/useVoiceRecorder-B8vxObZG.js.gz +0 -0
  104. package/payload/server/public/assets/{wrench-Bqux9jaw.js → wrench-DutUiRu7.js} +1 -1
  105. package/payload/server/public/assets/wrench-DutUiRu7.js.br +0 -0
  106. package/payload/server/public/assets/wrench-DutUiRu7.js.gz +0 -0
  107. package/payload/server/public/browser.html +4 -4
  108. package/payload/server/public/calendar.html +7 -7
  109. package/payload/server/public/chat.html +13 -13
  110. package/payload/server/public/data.html +11 -11
  111. package/payload/server/public/graph.html +9 -9
  112. package/payload/server/public/index.html +13 -13
  113. package/payload/server/public/operator.html +14 -14
  114. package/payload/server/public/public.html +13 -13
  115. package/payload/server/public/routines.html +7 -7
  116. package/payload/server/public/skills.html +5 -5
  117. package/payload/server/public/tasks.html +6 -6
  118. package/payload/server/server.js +1118 -636
  119. package/payload/server/public/assets/AdminLoginScreens-DLogIsJT.js.br +0 -0
  120. package/payload/server/public/assets/AdminLoginScreens-DLogIsJT.js.gz +0 -0
  121. package/payload/server/public/assets/AdminShell-B67tPTyE.js +0 -2
  122. package/payload/server/public/assets/AdminShell-B67tPTyE.js.br +0 -0
  123. package/payload/server/public/assets/AdminShell-B67tPTyE.js.gz +0 -0
  124. package/payload/server/public/assets/activity-AVJSFESV.js.br +0 -0
  125. package/payload/server/public/assets/activity-AVJSFESV.js.gz +0 -0
  126. package/payload/server/public/assets/admin-eFgbld42.js.br +0 -0
  127. package/payload/server/public/assets/admin-eFgbld42.js.gz +0 -0
  128. package/payload/server/public/assets/agents-CoWLZMf7.js.br +0 -0
  129. package/payload/server/public/assets/agents-CoWLZMf7.js.gz +0 -0
  130. package/payload/server/public/assets/browser-C6K66_fw.js.br +0 -0
  131. package/payload/server/public/assets/browser-C6K66_fw.js.gz +0 -0
  132. package/payload/server/public/assets/calendar-Dnw_cP4b.js.br +0 -0
  133. package/payload/server/public/assets/calendar-Dnw_cP4b.js.gz +0 -0
  134. package/payload/server/public/assets/chat-Cm0fkpg1.js +0 -1
  135. package/payload/server/public/assets/chat-Cm0fkpg1.js.br +0 -0
  136. package/payload/server/public/assets/chat-Cm0fkpg1.js.gz +0 -0
  137. package/payload/server/public/assets/chevron-left-CjRH-BtF.js +0 -1
  138. package/payload/server/public/assets/chevron-left-CjRH-BtF.js.br +0 -0
  139. package/payload/server/public/assets/clock-wbYEt1kM.js +0 -1
  140. package/payload/server/public/assets/clock-wbYEt1kM.js.br +0 -0
  141. package/payload/server/public/assets/clock-wbYEt1kM.js.gz +0 -0
  142. package/payload/server/public/assets/copy-kuNM0Etw.js.br +0 -0
  143. package/payload/server/public/assets/copy-kuNM0Etw.js.gz +0 -0
  144. package/payload/server/public/assets/data-c47T2NCd.js +0 -1
  145. package/payload/server/public/assets/data-c47T2NCd.js.br +0 -1
  146. package/payload/server/public/assets/data-c47T2NCd.js.gz +0 -0
  147. package/payload/server/public/assets/file-text-BWkobrcx.js.br +0 -0
  148. package/payload/server/public/assets/file-text-BWkobrcx.js.gz +0 -0
  149. package/payload/server/public/assets/graph-BGjTL5Vz.js.br +0 -0
  150. package/payload/server/public/assets/graph-BGjTL5Vz.js.gz +0 -0
  151. package/payload/server/public/assets/graph-labels-Dvc5Kw_r.js.br +0 -0
  152. package/payload/server/public/assets/graph-labels-Dvc5Kw_r.js.gz +0 -0
  153. package/payload/server/public/assets/operator-CzBGv_he.js.br +0 -0
  154. package/payload/server/public/assets/operator-CzBGv_he.js.gz +0 -0
  155. package/payload/server/public/assets/page-BIY4Ku8Q.js.br +0 -0
  156. package/payload/server/public/assets/page-BIY4Ku8Q.js.gz +0 -0
  157. package/payload/server/public/assets/page-DaySqamH.js.br +0 -0
  158. package/payload/server/public/assets/page-DaySqamH.js.gz +0 -0
  159. package/payload/server/public/assets/play-C8RA52_i.js +0 -1
  160. package/payload/server/public/assets/play-C8RA52_i.js.br +0 -0
  161. package/payload/server/public/assets/play-C8RA52_i.js.gz +0 -0
  162. package/payload/server/public/assets/public-pwDSvhBv.js.br +0 -0
  163. package/payload/server/public/assets/public-pwDSvhBv.js.gz +0 -0
  164. package/payload/server/public/assets/rotate-ccw-B6s7tkEt.js.br +0 -0
  165. package/payload/server/public/assets/rotate-ccw-B6s7tkEt.js.gz +0 -0
  166. package/payload/server/public/assets/routines-CFlyRdYg.js.br +0 -0
  167. package/payload/server/public/assets/routines-CFlyRdYg.js.gz +0 -0
  168. package/payload/server/public/assets/skills-C45kfL_Q.js.br +0 -0
  169. package/payload/server/public/assets/skills-C45kfL_Q.js.gz +0 -0
  170. package/payload/server/public/assets/tasks-BwoCTCu5.js.br +0 -0
  171. package/payload/server/public/assets/tasks-BwoCTCu5.js.gz +0 -0
  172. package/payload/server/public/assets/triangle-alert-B3bthSzx.js.br +0 -0
  173. package/payload/server/public/assets/triangle-alert-B3bthSzx.js.gz +0 -0
  174. package/payload/server/public/assets/useCopyFeedback-BzJAkFEb.js.br +0 -0
  175. package/payload/server/public/assets/useCopyFeedback-BzJAkFEb.js.gz +0 -0
  176. package/payload/server/public/assets/useMediaQuery-DJ6XeNN7.js +0 -14
  177. package/payload/server/public/assets/useMediaQuery-DJ6XeNN7.js.br +0 -0
  178. package/payload/server/public/assets/useMediaQuery-DJ6XeNN7.js.gz +0 -0
  179. package/payload/server/public/assets/useMediaQuery-DW_xRrMl.css +0 -1
  180. package/payload/server/public/assets/useMediaQuery-DW_xRrMl.css.br +0 -0
  181. package/payload/server/public/assets/useMediaQuery-DW_xRrMl.css.gz +0 -0
  182. package/payload/server/public/assets/useVoiceRecorder-_qI_BAOo.js.br +0 -0
  183. package/payload/server/public/assets/useVoiceRecorder-_qI_BAOo.js.gz +0 -0
  184. package/payload/server/public/assets/wrench-Bqux9jaw.js.br +0 -0
  185. package/payload/server/public/assets/wrench-Bqux9jaw.js.gz +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.562",
3
+ "version": "0.1.563",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: platform-architecture
3
3
  description: Use when grounding any documented-surface claim about what Maxy ships — plugins, skills, specialists, install/deploy flows, internals. This is the install catalogue, not evidence of what is enabled on the current account. For install state on this account, call `capabilities-here`; for documented surface, cite the `Source:` URL inline.
4
- content-hash: sha256:2a6a3a040c8d55a472cfc2eda9180b15e5a0ebd8e5ce49a997c4348570ffd37b
4
+ content-hash: sha256:5b93e9b58f687c920da46bec1137f326c5c00d5f2b1d4a460a5f5248eaee0c15
5
5
  brand: maxy-code
6
6
  product-name: Maxy
7
7
  ---
@@ -294,9 +294,11 @@ The Data search panel ranks results by combining vector similarity with keyword
294
294
 
295
295
  ## Software Update and Cloudflare Setup
296
296
 
297
- Both flows run on the native Claude Code PTY surface in admin chat. There is no in-app upgrade modal and no Cloudflare setup form the agent invokes the relevant Bash command directly and its stdout streams into chat verbatim.
297
+ Cloudflare setup runs on the native Claude Code PTY surface in admin chat: there is no Cloudflare setup form, the agent invokes the relevant Bash command directly and its stdout streams into chat verbatim.
298
298
 
299
- - **Software update.** Re-run the installer (`npx -y @rubytech/create-<brand>@latest`) from a shell; HeaderMenu's version row turns sage when `installed === latest`.
299
+ Software update has two doors. The PTY one is the same shape: the agent runs the installer via Bash and its stdout streams into chat. The dashboard one is a modal over the edge-hosted `/api/admin/actions` routes, which run the installer as a `systemd-run` transient unit and stream its log back by byte offset. Both re-run the same installer; neither mutates state directly.
300
+
301
+ - **Software update.** Re-run the installer (`npx -y @rubytech/create-<brand>@latest`), from a shell, from admin chat via the `upgrade` skill, or from the dashboard control in the sidebar brand head. The brand head shows the installed version at all times and grows a download icon only while `updateAvailable` is true.
300
302
  - **Cloudflare setup.** Operator asks in chat; the agent invokes `cloudflared` directly via the Bash tool, following the numbered steps in `plugins/cloudflare/references/manual-setup.md`. cloudflared's stdout and stderr stream into the PTY; the OAuth URL printed by `cloudflared tunnel login` is linkified by the terminal so the operator clicks it and authorises Cloudflare in their own browser.
301
303
 
302
304
  **Mid-turn stream-drop banners.** If a chat turn ends abruptly the bubble shows one of two messages depending on what actually happened. You see "Server is restarting — reconnect will happen automatically." only when the app server itself emits the restart signal — typically during a Software Update or a Cloudflare setup that re-launches the brand service. You see "Lost connection — retrying." when your browser's connection to the Pi dropped mid-stream while the server was still up — typically a flaky Wi-Fi moment or the tunnel hiccupping. Either way the chat resumes once the connection is back; the previously-rendered messages stay on screen so you don't lose context.
@@ -6110,11 +6112,11 @@ The logs will show which service failed to start and why. Common causes:
6110
6112
  Each installed brand runs two per-brand `--user` systemd units (earlier platform fixes + — unit filenames are prefixed with the brand's `hostname` so two brands on the same device never share a unit file):
6111
6113
 
6112
6114
  - `{hostname}.service` — the admin + public HTTP server on `127.0.0.1:19201` (public port + 1). Restarted by the upgrade flow; short downtime is expected during steps 8→11 of an upgrade. An earlier fix: the unit carries two port env vars — `PORT=<public>` (canonical public port, read by the upgrade detector) and `MAXY_UI_INTERNAL_PORT=<public+1>` (the port maxy-ui actually binds).
6113
- - `{hostname}-edge.service` — the always-on public listener on the configured port (default 19200). Reverse-proxies HTTP to the main brand service and handles `/websockify` (VNC) WebSocket upgrades locally. An earlier fix: also hosts `/api/admin/actions/*` and `/api/admin/version*` — the Software Update modal's own routes — so the log stream survives the brand service's restart window. Does NOT restart during an upgrade the browser WebSocket stays connected by construction. The operator views the device's Chromium in the admin UI via the standalone `/browser` page (peer to `/graph` and `/data`) and on the Claude sign-in screen; both render CDP screencast frames over `/cdp-screencast`, not the framebuffer. Virtual-mode Chromium is headless, so nothing renders onto the X display and the `/websockify` upgrade has no remaining consumer; deleting that stack is separate work.
6115
+ - `{hostname}-edge.service` — the always-on public listener on the configured port (default 19200). Reverse-proxies HTTP to the main brand service and handles `/websockify` (VNC) WebSocket upgrades locally. An earlier fix: also hosts `/api/admin/actions/*` and `/api/admin/version*` — the Software Update modal's own routes — so they survive the brand service's restart window. It is restarted by the upgrade flow too (`packages/create-maxy-code/src/index.ts:5143`, after the brand service is stopped and before it is restarted), so the update stream drops near the end of every run and resumes by byte offset. It is not a connection that survives; the log file is what survives, and the browser's `EventSource` replays `Last-Event-ID` to pick the tail back up. The operator views the device's Chromium in the admin UI via the standalone `/browser` page (peer to `/graph` and `/data`) and on the Claude sign-in screen; both render CDP screencast frames over `/cdp-screencast`, not the framebuffer. Virtual-mode Chromium is headless, so nothing renders onto the X display and the `/websockify` upgrade has no remaining consumer; deleting that stack is separate work.
6114
6116
 
6115
- Upgrade and Cloudflare setup run as detached actions: `systemd-run --user` transient units per invocation with stdout+stderr persisted to `~/.maxy/logs/actions/<actionId>.log` and streamed to the UI via SSE. No boot-time service file exists for these.
6117
+ The upgrade runs as a detached action: a `systemd-run --user --unit=maxy-action-<actionId> --collect` transient unit per invocation, with stdout+stderr persisted to `~/.<brand>/logs/actions/<actionId>.log` and streamed to the UI via SSE. The unit has its own cgroup, so it outlives both service restarts the installer performs. Its final log line is `[action-exit] code=<n>`, written by the wrapper, which is how the exit code survives a unit that `--collect` has already reaped. A sidecar `~/.<brand>/logs/actions/<actionId>.json` holds the package, start time and pre-upgrade version, so a modal reopened after a reload can still report what changed. No boot-time service file exists for these. Cloudflare setup is not an action; it runs PTY-native from admin chat.
6116
6118
 
6117
- If an action looks stuck, read `~/.maxy/logs/actions/<actionId>.log` directly for the full output, or `journalctl --user --identifier=maxy-action-<actionId>` for systemd's record.
6119
+ If an action looks stuck, read `~/.<brand>/logs/actions/<actionId>.log` directly for the full output, or `journalctl --user --identifier=maxy-action-<actionId>` for systemd's record. The lifecycle lines are tagged `[admin-action]` and keyed by `actionId`; they land in `~/.<brand>/logs/edge.log`, not `server.log`, because these routes run on the edge process.
6118
6120
 
6119
6121
  ## Linux laptops: snap-confined Chromium replacement
6120
6122
 
@@ -6223,17 +6225,23 @@ Empty output from step 3 = brand.json resolved cleanly and the badge reflects th
6223
6225
 
6224
6226
  ## Upgrading
6225
6227
 
6226
- To upgrade Maxy to the latest version, ask Maxy: "Upgrade Maxy." The platform checks the current device identity (hostname and port via `system-status`), then re-runs the installer with explicit `--hostname` and `--port` flags to preserve them across the upgrade.
6228
+ There are two ways to upgrade, and both run the same installer.
6229
+
6230
+ From the dashboard, press the download icon in the sidebar header (see below). From chat, ask Maxy: "Upgrade Maxy." The platform checks the current device identity (hostname and port via `system-status`), then re-runs the installer with explicit `--hostname` and `--port` flags to preserve them across the upgrade.
6227
6231
 
6228
6232
  The docs plugin (this plugin) is upgraded in the same step — you always have the documentation that matches your installed version.
6229
6233
 
6230
- ### Automatic upgrade alert
6234
+ ### Upgrading from the dashboard
6235
+
6236
+ The sidebar header shows the version you are running, under the product name, at all times. When a newer one is published a download icon appears beside it. There is no icon when you are already up to date, so the icon appearing is itself the news. If you work with the sidebar collapsed to the narrow rail, the version and the icon are hidden with the rest of the header text and a small coloured dot on the brand icon carries the same signal.
6237
+
6238
+ Pressing the icon opens the Software Update window, which shows the version you have against the version available and an Update button. Pressing Update runs the installer and streams its output into the window as it happens.
6231
6239
 
6232
- Maxy checks for new releases on every admin session start whenever you log in, reload the page, or return to the admin chat. When a newer version is available, the Software Update window opens automatically showing your current and the latest version, with a one-click Upgrade button. Dismissing the window (click outside or the close button) defers the alert until your next login or reload; no alert is shown when you are already on the latest version.
6240
+ Closing the window does not cancel the upgrade. The installer runs in its own background unit, so it keeps going whether or not anything is watching, and reopening the window reattaches to the run in progress.
6233
6241
 
6234
- The upgrade runs inside a live terminal embedded in the Software Update window you see each installation step stream as it happens, and any password prompts from `sudo` appear directly in the terminal for you to answer. Closing the window does not cancel the upgrade; re-opening it reattaches to the same shell so you can see what happened while disconnected.
6242
+ **The connection drops near the end of every upgrade, and that is expected.** The installer restarts the server as its last step, which cuts the browser's connection to it. The window says "Reconnecting" while that happens, then picks the output back up from exactly where it left off. Nothing is lost and nothing needs restarting. When it finishes, the window reports the exit code and whether the version actually moved.
6235
6243
 
6236
- The header menu's version indicator still reflects real-time status: a green dot means you are up to date, and an accent-coloured dot means an upgrade is available. Opening the menu refreshes the version check, so a long-lived session can still surface an upgrade that became available after login without reloading the page.
6244
+ The window reports a genuine failure in two shapes worth knowing. If the installer exits non-zero it says so with the code. If it exits cleanly but the version did not change, it says that too, rather than claiming success.
6237
6245
 
6238
6246
  ---
6239
6247
  # Samba Share
@@ -9,6 +9,12 @@ Invoked by the admin agent directly.
9
9
 
10
10
  This is the platform's release timeline, newest first. Each entry shows the date it shipped and the version it shipped in, so you can tell the operator how current their install is. To compare, read the installed version from `capabilities-here` and match it against the versions below. Keep answers high level and in plain English; this is a summary, not a full commit log.
11
11
 
12
+ ## 2026-08-05 (0.1.563)
13
+
14
+ - You can now update from the dashboard. The installed version sits in the sidebar header rather than buried in the footer menu, and when a newer one is available the control there starts the update and shows its progress live. Previously the only route was asking the assistant in chat, and an available update was invisible until you opened the menu.
15
+ - A public bot no longer loses the first message it is sent. A message that arrived while the conversation was still starting up was handed over a moment too early and discarded silently; delivery now waits until the conversation is ready to receive it.
16
+ - The data portal can now hold records, not just files. A portal login can be scoped to see only the records assigned to that person rather than everything for the account, work assigned in the office appears on the portal, and what is filled in on site comes back. A standing check reports any exchange that stalls.
17
+
12
18
  ## 2026-08-05 (0.1.562)
13
19
 
14
20
  - A public agent no longer sees anything about you or your machine. A stranger messaging a public bot was having your connected mailbox list, this machine's name and network addresses, its admin and tunnel web addresses, today's date and your working preferences added to every turn, alongside the persona you wrote. A public turn now carries only what the agent's author wrote plus its single reply tool.
@@ -108,6 +108,17 @@ if (foldersArg !== undefined) {
108
108
  folders = [...new Set(names)].join(',')
109
109
  }
110
110
 
111
+ // Which RECORDS this person reads, as distinct from --folders, which is which
112
+ // files they browse. Two values and no third.
113
+ //
114
+ // The absent flag is the NARROW one. Nobody is granted account-wide visibility
115
+ // by leaving a flag off, and a typo is refused rather than resolved to
116
+ // whichever value happens to be nearer — there is no third scope for it to be.
117
+ const recordScope = arg('--record-scope') ?? 'own'
118
+ if (recordScope !== 'own' && recordScope !== 'account') {
119
+ die('--record-scope must be "own" or "account" — there is no third scope')
120
+ }
121
+
111
122
  const saltBytes = new Uint8Array(16)
112
123
  crypto.getRandomValues(saltBytes)
113
124
  const saltHex = randomSaltHex(saltBytes)
@@ -155,14 +166,17 @@ const createdAt = new Date().toISOString()
155
166
  // The dedicated path keeps `accountId` in the DO UPDATE exactly as before, so a
156
167
  // grandfathered portal's behaviour is unchanged in every respect.
157
168
  //
158
- // `folders` IS in the DO UPDATE on both: re-enrolment replaces the grant, so a
159
- // narrower or wider set on re-run takes effect rather than sticking.
169
+ // `folders` and `recordScope` are BOTH in the DO UPDATE on both models:
170
+ // re-enrolment replaces each, so a narrower or wider setting on a re-run takes
171
+ // effect rather than sticking. Narrowing matters most — a person moved off the
172
+ // account-wide scope must actually lose it, and the re-run is the only path
173
+ // there is.
160
174
  const conflict =
161
175
  storageModel === 'shared'
162
- ? 'ON CONFLICT(accountId, ownerId) DO UPDATE SET name=excluded.name, salt=excluded.salt, hash=excluded.hash, folders=excluded.folders'
163
- : 'ON CONFLICT(ownerId) DO UPDATE SET name=excluded.name, salt=excluded.salt, hash=excluded.hash, accountId=excluded.accountId, folders=excluded.folders'
176
+ ? 'ON CONFLICT(accountId, ownerId) DO UPDATE SET name=excluded.name, salt=excluded.salt, hash=excluded.hash, folders=excluded.folders, recordScope=excluded.recordScope'
177
+ : 'ON CONFLICT(ownerId) DO UPDATE SET name=excluded.name, salt=excluded.salt, hash=excluded.hash, accountId=excluded.accountId, folders=excluded.folders, recordScope=excluded.recordScope'
164
178
  console.log(
165
- `INSERT INTO people (ownerId, name, salt, hash, createdAt, accountId, folders) VALUES ('${ownerId}', '${sqlName}', '${saltHex}', '${hash}', '${createdAt}', '${accountId}', '${folders}') ${conflict};`,
179
+ `INSERT INTO people (ownerId, name, salt, hash, createdAt, accountId, folders, recordScope) VALUES ('${ownerId}', '${sqlName}', '${saltHex}', '${hash}', '${createdAt}', '${accountId}', '${folders}', '${recordScope}') ${conflict};`,
166
180
  )
167
181
 
168
182
  // stderr: everything for the human. Never stdout — see the note at the top.
@@ -102,6 +102,9 @@ describe('sessions', () => {
102
102
  ownerId: 'alice',
103
103
  accountId: ACC,
104
104
  folders: [],
105
+ // The fake's people row carries no recordScope, which is a legacy row.
106
+ // It resolves narrow, not wide (Task 2425).
107
+ recordScope: 'own',
105
108
  })
106
109
  })
107
110
 
@@ -186,6 +189,9 @@ describe('session revocation — the property the design claims', () => {
186
189
  ownerId: 'alice',
187
190
  accountId: ACC,
188
191
  folders: [],
192
+ // The fake's people row carries no recordScope, which is a legacy row.
193
+ // It resolves narrow, not wide (Task 2425).
194
+ recordScope: 'own',
189
195
  })
190
196
 
191
197
  peopleRows[0].hash = 'HASH-v2-rotated0' // operator re-runs enrolment
@@ -32,7 +32,7 @@ function parse(r: { stdout: string; stderr: string }) {
32
32
  }
33
33
 
34
34
  const valuesOf = (sql: string) =>
35
- /VALUES \('([^']*)', '((?:[^']|'')*)', '([0-9a-f]+)', '([0-9a-f]+)', '([^']*)', '([^']*)', '([^']*)'\)/.exec(
35
+ /VALUES \('([^']*)', '((?:[^']|'')*)', '([0-9a-f]+)', '([0-9a-f]+)', '([^']*)', '([^']*)', '([^']*)', '([^']*)'\)/.exec(
36
36
  sql,
37
37
  )!
38
38
 
@@ -129,9 +129,9 @@ describe('portal-enrol', () => {
129
129
  const r = await enrol(['--owner', 'alice', '--name', 'Alice'])
130
130
  const { sql } = parse(r)
131
131
  expect(sql).toContain(
132
- 'INSERT INTO people (ownerId, name, salt, hash, createdAt, accountId, folders)',
132
+ 'INSERT INTO people (ownerId, name, salt, hash, createdAt, accountId, folders, recordScope)',
133
133
  )
134
- const [, owner, name, salt, hash, createdAt, accountId, folders] = valuesOf(sql!)
134
+ const [, owner, name, salt, hash, createdAt, accountId, folders, recordScope] = valuesOf(sql!)
135
135
  expect(owner).toBe('alice')
136
136
  expect(name).toBe('Alice')
137
137
  expect(salt).toMatch(/^[0-9a-f]{32}$/)
@@ -139,6 +139,57 @@ describe('portal-enrol', () => {
139
139
  expect(Number.isNaN(Date.parse(createdAt))).toBe(false)
140
140
  expect(accountId).toBe('acc-1')
141
141
  expect(folders).toBe('')
142
+ expect(recordScope).toBe('own')
143
+ })
144
+
145
+ describe('record scope (Task 2425)', () => {
146
+ it('defaults to own when the flag is absent', async () => {
147
+ const { sql } = parse(await enrol(['--owner', 'alice', '--name', 'Alice']))
148
+ expect(valuesOf(sql!)[8]).toBe('own')
149
+ })
150
+
151
+ it('writes account when asked for it', async () => {
152
+ const { sql } = parse(
153
+ await enrol(['--owner', 'marie', '--name', 'Marie', '--record-scope', 'account']),
154
+ )
155
+ expect(valuesOf(sql!)[8]).toBe('account')
156
+ })
157
+
158
+ it('dies on a third value, before any SQL reaches stdout', async () => {
159
+ // There is no third scope. A typo must not silently land as one of the
160
+ // two, and it must not land at all: stdout is empty, so the documented
161
+ // pipe carries nothing into wrangler.
162
+ await expect(
163
+ enrol(['--owner', 'alice', '--name', 'Alice', '--record-scope', 'admin']),
164
+ ).rejects.toMatchObject({ stdout: '' })
165
+ })
166
+
167
+ it('replaces the scope on re-enrolment, on both storage models', async () => {
168
+ const shared = parse(
169
+ await enrol(['--owner', 'a', '--name', 'A', '--record-scope', 'account']),
170
+ )
171
+ expect(shared.sql).toContain('recordScope=excluded.recordScope')
172
+ const dedicated = parse(
173
+ await enrol(['--owner', 'a', '--name', 'A', '--storage-model', 'dedicated']),
174
+ )
175
+ expect(dedicated.sql).toContain('recordScope=excluded.recordScope')
176
+ })
177
+
178
+ it('lands in a real database and the re-run narrows it back', async () => {
179
+ // Against the template's own schema.sql, not a fake: a fake would model
180
+ // the very constraint we are proving we satisfy, which is how 1689
181
+ // shipped a UNIQUE-violating upload.
182
+ const db = await d1('portal-record-scope-')
183
+ const wide = parse(
184
+ await enrol(['--owner', 'alice', '--name', 'Alice', '--record-scope', 'account']),
185
+ )
186
+ await db.exec(wide.sql!)
187
+ expect(await db.query("SELECT recordScope FROM people WHERE ownerId='alice'")).toBe('account')
188
+
189
+ const narrow = parse(await enrol(['--owner', 'alice', '--name', 'Alice']))
190
+ await db.exec(narrow.sql!)
191
+ expect(await db.query("SELECT recordScope FROM people WHERE ownerId='alice'")).toBe('own')
192
+ })
142
193
  })
143
194
 
144
195
  it('refuses enrolment with no --account', async () => {
@@ -175,8 +226,14 @@ describe('portal-enrol', () => {
175
226
  })
176
227
 
177
228
  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) {
229
+ /** A pre-2099 portal's own database: `people` keyed on ownerId alone.
230
+ *
231
+ * `withRecordScope` is the hand `ALTER` schema.sql tells an operator to run
232
+ * on a table that predates the column (Task 2425). Both shapes are real and
233
+ * the tests below need each: without it, enrolment must fail loudly, which
234
+ * is what the schema comment claims; with it, rotation must keep working,
235
+ * which is what the composite-target regression is about. */
236
+ async function legacyDb(dir: string, withRecordScope = true) {
180
237
  const { mkdtemp } = await import('node:fs/promises')
181
238
  const { tmpdir } = await import('node:os')
182
239
  const root = await mkdtemp(resolve(tmpdir(), dir))
@@ -185,7 +242,10 @@ describe('portal-enrol', () => {
185
242
  db,
186
243
  'CREATE TABLE people (ownerId TEXT PRIMARY KEY, name TEXT NOT NULL, salt TEXT NOT NULL, ' +
187
244
  "hash TEXT NOT NULL, createdAt TEXT NOT NULL, accountId TEXT NOT NULL DEFAULT '', " +
188
- "folders TEXT NOT NULL DEFAULT '');",
245
+ "folders TEXT NOT NULL DEFAULT '');" +
246
+ (withRecordScope
247
+ ? " ALTER TABLE people ADD COLUMN recordScope TEXT NOT NULL DEFAULT 'own';"
248
+ : ''),
189
249
  ])
190
250
  return {
191
251
  exec: (sql: string) => run('sqlite3', [db, sql]),
@@ -193,6 +253,17 @@ describe('portal-enrol', () => {
193
253
  }
194
254
  }
195
255
 
256
+ it('fails loudly on a table that never got the recordScope column', async () => {
257
+ // schema.sql claims enrolment's INSERT names the column, so a table
258
+ // predating it fails loudly rather than writing a row with no scope. A
259
+ // property a comment asserts has to be a property the code has.
260
+ const db = await legacyDb('enrol-legacy-noscope-', false)
261
+ const r = parse(
262
+ await enrol(['--owner', 'dale', '--name', 'Dale', '--account', 'acct-a', '--storage-model', 'dedicated']),
263
+ )
264
+ await expect(db.exec(r.sql!)).rejects.toThrow(/no column named recordScope/)
265
+ })
266
+
196
267
  it('lets two tenants each enrol the same person on the shared store', async () => {
197
268
  const db = await d1('enrol-two-tenants-')
198
269
  await db.exec(parse(await enrol(['--owner', 'rob', '--name', 'Rob', '--account', 'acct-a'])).sql!)
@@ -72,6 +72,45 @@ describe('resolveSession folders', () => {
72
72
  })
73
73
  })
74
74
 
75
+ describe('resolveSession recordScope', () => {
76
+ const row = (recordScope?: unknown) => ({
77
+ ownerId: 'alice',
78
+ accountId: 'acc-a',
79
+ folders: '',
80
+ ...(recordScope === undefined ? {} : { recordScope }),
81
+ })
82
+
83
+ it('returns account for a person who holds it', async () => {
84
+ const s = await resolveSession(db(row('account')), 'sess', 'acc-a', 1)
85
+ expect(s?.recordScope).toBe('account')
86
+ })
87
+
88
+ it('returns own for a person who holds it', async () => {
89
+ const s = await resolveSession(db(row('own')), 'sess', 'acc-a', 1)
90
+ expect(s?.recordScope).toBe('own')
91
+ })
92
+
93
+ // Every unreadable reading is the NARROW one. Only the exact token widens.
94
+ // Reverting the fallback to 'account' must turn all four of these red — a
95
+ // read failure that grants account-wide visibility is the one outcome this
96
+ // column exists to prevent.
97
+ it('falls back to own for an empty string', async () => {
98
+ expect((await resolveSession(db(row('')), 'sess', 'acc-a', 1))?.recordScope).toBe('own')
99
+ })
100
+
101
+ it('falls back to own for an unknown token', async () => {
102
+ expect((await resolveSession(db(row('admin')), 'sess', 'acc-a', 1))?.recordScope).toBe('own')
103
+ })
104
+
105
+ it('falls back to own for a null value', async () => {
106
+ expect((await resolveSession(db(row(null)), 'sess', 'acc-a', 1))?.recordScope).toBe('own')
107
+ })
108
+
109
+ it('falls back to own for a legacy row with no column at all', async () => {
110
+ expect((await resolveSession(db(row()), 'sess', 'acc-a', 1))?.recordScope).toBe('own')
111
+ })
112
+ })
113
+
75
114
  describe('grantAllows', () => {
76
115
  it('admits everything when the grant is empty', () => {
77
116
  expect(grantAllows([], 'documents/secret.pdf')).toBe(true)
@@ -0,0 +1,176 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ MAX_STATUS_LENGTH,
4
+ processVisitStatus,
5
+ } from '../../skills/data-portal/template/functions/api/visit-status'
6
+ import type {
7
+ D1Database,
8
+ PortalEnv,
9
+ } from '../../skills/data-portal/template/functions/api/_lib/types'
10
+
11
+ interface Row {
12
+ accountId: string
13
+ ownerId: string
14
+ visitId: string
15
+ }
16
+
17
+ /**
18
+ * A fake that HONOURS the INSERT ... SELECT, because the gate IS the SELECT.
19
+ *
20
+ * It applies the same account and owner filters the statement names and reports
21
+ * `meta.changes` as the number of rows the SELECT admitted. A fake that always
22
+ * reported one change would pass a handler with no gate at all.
23
+ */
24
+ function db(rows: Row[], person: { ownerId: string; recordScope: string; accountId: string }) {
25
+ const inserted: unknown[][] = []
26
+ const database = {
27
+ prepare(sql: string) {
28
+ let bound: unknown[] = []
29
+ const stmt = {
30
+ bind(...v: unknown[]) {
31
+ bound = v
32
+ return stmt
33
+ },
34
+ async first<T>() {
35
+ return person as unknown as T
36
+ },
37
+ async run() {
38
+ if (!sql.startsWith('INSERT INTO visit_status')) return { meta: { changes: 0 } }
39
+ // Bind order matches the statement: the SELECT's literals first, then
40
+ // the WHERE's account, the optional owner, and the visitId last.
41
+ const acct = bound[0] as string
42
+ const visitId = bound[bound.length - 1] as string
43
+ const owner = bound[1] as string
44
+ let admitted = rows.filter((r) => r.accountId === acct && r.visitId === visitId)
45
+ if (sql.includes('AND v.ownerId = ?')) admitted = admitted.filter((r) => r.ownerId === owner)
46
+ if (admitted.length) inserted.push(bound)
47
+ return { meta: { changes: admitted.length } }
48
+ },
49
+ async all<T>() {
50
+ return { results: [] as T[] }
51
+ },
52
+ }
53
+ return stmt
54
+ },
55
+ }
56
+ return { database: database as unknown as D1Database, inserted }
57
+ }
58
+
59
+ const env = (d: D1Database): PortalEnv =>
60
+ ({ DB: d, BUCKET: {}, PORTAL_ACCOUNT_ID: 'acc-a' }) as unknown as PortalEnv
61
+
62
+ const ROWS: Row[] = [
63
+ { accountId: 'acc-a', ownerId: 'alice', visitId: 'v1' },
64
+ { accountId: 'acc-a', ownerId: 'bob', visitId: 'v2' },
65
+ { accountId: 'acc-a', ownerId: '', visitId: 'v3' },
66
+ { accountId: 'acc-b', ownerId: 'alice', visitId: 'v4' },
67
+ ]
68
+
69
+ const person = (ownerId: string, recordScope: string) => ({
70
+ ownerId,
71
+ recordScope,
72
+ accountId: 'acc-a',
73
+ })
74
+
75
+ const body = (visitId: string, status = 'on-site') => ({ visitId, status })
76
+
77
+ describe('the visit-status write gate', () => {
78
+ it('accepts an own-scoped person setting status on their own visit', async () => {
79
+ const { database, inserted } = db(ROWS, person('alice', 'own'))
80
+ const h = await processVisitStatus(body('v1'), 'sess', env(database), () => {}, 1)
81
+ expect(h.status).toBe(200)
82
+ expect(inserted).toHaveLength(1)
83
+ })
84
+
85
+ it('refuses an own-scoped person setting status on someone else visit', async () => {
86
+ // A read leak shows someone a day that is not theirs. A write leak lets
87
+ // them change it, which is why the gate is the same one and is structural.
88
+ const { database, inserted } = db(ROWS, person('alice', 'own'))
89
+ const h = await processVisitStatus(body('v2'), 'sess', env(database), () => {}, 1)
90
+ expect(h.status).toBe(404)
91
+ expect(inserted).toEqual([])
92
+ })
93
+
94
+ it('refuses an own-scoped person setting status on an unbound visit', async () => {
95
+ const { database, inserted } = db(ROWS, person('alice', 'own'))
96
+ const h = await processVisitStatus(body('v3'), 'sess', env(database), () => {}, 1)
97
+ expect(h.status).toBe(404)
98
+ expect(inserted).toEqual([])
99
+ })
100
+
101
+ it('lets an account-scoped person set status on any visit for the account', async () => {
102
+ const { database, inserted } = db(ROWS, person('marie', 'account'))
103
+ const h = await processVisitStatus(body('v2'), 'sess', env(database), () => {}, 1)
104
+ expect(h.status).toBe(200)
105
+ expect(inserted).toHaveLength(1)
106
+ })
107
+
108
+ it('refuses another account visit even at account scope', async () => {
109
+ const { database, inserted } = db(ROWS, person('marie', 'account'))
110
+ const h = await processVisitStatus(body('v4'), 'sess', env(database), () => {}, 1)
111
+ expect(h.status).toBe(404)
112
+ expect(inserted).toEqual([])
113
+ })
114
+
115
+ it('denies when the session does not resolve', async () => {
116
+ const d = {
117
+ prepare: () => ({ bind: () => ({ first: async () => null }) }),
118
+ } as unknown as D1Database
119
+ const h = await processVisitStatus(body('v1'), '', env(d), () => {}, 1)
120
+ expect(h.status).toBe(401)
121
+ })
122
+
123
+ it('refuses a body with no visitId or no status', async () => {
124
+ const { database, inserted } = db(ROWS, person('alice', 'own'))
125
+ const e = env(database)
126
+ expect((await processVisitStatus({ status: 'x' }, 'sess', e, () => {}, 1)).status).toBe(400)
127
+ expect((await processVisitStatus({ visitId: 'v1' }, 'sess', e, () => {}, 1)).status).toBe(400)
128
+ expect((await processVisitStatus(null, 'sess', e, () => {}, 1)).status).toBe(400)
129
+ expect(inserted).toEqual([])
130
+ })
131
+
132
+ it('refuses a status longer than the cap, rather than writing it into the graph', async () => {
133
+ // The value reaches :Visit.status through the pull. An unbounded free-text
134
+ // field written from the open web is refused at the door, not truncated:
135
+ // a truncated status is a different status, silently.
136
+ const { database, inserted } = db(ROWS, person('alice', 'own'))
137
+ const h = await processVisitStatus(
138
+ body('v1', 'x'.repeat(MAX_STATUS_LENGTH + 1)),
139
+ 'sess',
140
+ env(database),
141
+ () => {},
142
+ 1,
143
+ )
144
+ expect(h.status).toBe(400)
145
+ expect(inserted).toEqual([])
146
+ })
147
+
148
+ it('stamps the setter own id, never one the request supplies', async () => {
149
+ const { database, inserted } = db(ROWS, person('alice', 'own'))
150
+ await processVisitStatus(
151
+ { visitId: 'v1', status: 'on-site', ownerId: 'bob' },
152
+ 'sess',
153
+ env(database),
154
+ () => {},
155
+ 1,
156
+ )
157
+ expect(inserted[0]).toContain('alice')
158
+ expect(inserted[0]).not.toContain('bob')
159
+ })
160
+
161
+ it('logs the scope and the outcome, and no status text', async () => {
162
+ const lines: string[] = []
163
+ const { database } = db(ROWS, person('alice', 'own'))
164
+ await processVisitStatus(
165
+ body('v1', 'on-site-at-granary-court'),
166
+ 'sess',
167
+ env(database),
168
+ (l) => lines.push(l),
169
+ 1,
170
+ )
171
+ const joined = lines.join('\n')
172
+ expect(joined).toContain('scope=own')
173
+ expect(joined).toContain('result=ok')
174
+ expect(joined).not.toContain('granary')
175
+ })
176
+ })
@@ -0,0 +1,112 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { processVisits } from '../../skills/data-portal/template/functions/api/visits'
3
+ import type {
4
+ D1Database,
5
+ PortalEnv,
6
+ } from '../../skills/data-portal/template/functions/api/_lib/types'
7
+
8
+ interface Row {
9
+ accountId: string
10
+ ownerId: string
11
+ visitId: string
12
+ }
13
+
14
+ /**
15
+ * A fake that HONOURS the SQL it is handed rather than replaying a fixture.
16
+ *
17
+ * It reads the WHERE clause and the bound values and filters accordingly, so
18
+ * deleting the scope branch in the endpoint changes what comes back. A fake
19
+ * that replayed rows would pass whatever the gate did, which is the same defect
20
+ * shape as 1689's upload fake accepting duplicates the schema rejected.
21
+ */
22
+ function db(rows: Row[], person: { ownerId: string; recordScope: string; accountId: string }) {
23
+ return {
24
+ prepare(sql: string) {
25
+ let bound: unknown[] = []
26
+ const stmt = {
27
+ bind(...v: unknown[]) {
28
+ bound = v
29
+ return stmt
30
+ },
31
+ async first<T>() {
32
+ return person as unknown as T
33
+ },
34
+ async run() {
35
+ return { meta: { changes: 0 } }
36
+ },
37
+ async all<T>() {
38
+ if (!sql.includes('FROM visits')) return { results: [] as T[] }
39
+ const [acct, owner] = bound as string[]
40
+ let out = rows.filter((r) => r.accountId === acct)
41
+ if (sql.includes('v.ownerId = ?')) out = out.filter((r) => r.ownerId === owner)
42
+ return { results: out as unknown as T[] }
43
+ },
44
+ }
45
+ return stmt
46
+ },
47
+ } as unknown as D1Database
48
+ }
49
+
50
+ const env = (d: D1Database): PortalEnv =>
51
+ ({ DB: d, BUCKET: {}, PORTAL_ACCOUNT_ID: 'acc-a' }) as unknown as PortalEnv
52
+
53
+ const ROWS: Row[] = [
54
+ { accountId: 'acc-a', ownerId: 'alice', visitId: 'v1' },
55
+ { accountId: 'acc-a', ownerId: 'bob', visitId: 'v2' },
56
+ { accountId: 'acc-a', ownerId: '', visitId: 'v3' },
57
+ { accountId: 'acc-b', ownerId: 'alice', visitId: 'v4' },
58
+ ]
59
+
60
+ const ids = (h: { payload: Record<string, unknown> }) =>
61
+ (h.payload.visits as Row[]).map((v) => v.visitId).sort()
62
+
63
+ const asPerson = (ownerId: string, recordScope: string) => ({
64
+ ownerId,
65
+ recordScope,
66
+ accountId: 'acc-a',
67
+ })
68
+
69
+ describe('the visits scope gate', () => {
70
+ it('shows an own-scoped person only their own visits', async () => {
71
+ const h = await processVisits('sess', env(db(ROWS, asPerson('alice', 'own'))), () => {}, 1)
72
+ expect(ids(h)).toEqual(['v1'])
73
+ })
74
+
75
+ it('shows an account-scoped person every visit for the account', async () => {
76
+ const h = await processVisits('sess', env(db(ROWS, asPerson('marie', 'account'))), () => {}, 1)
77
+ expect(ids(h)).toEqual(['v1', 'v2', 'v3'])
78
+ })
79
+
80
+ it('never returns another account rows, at either scope', async () => {
81
+ // The tenant filter is the baked-in var, not the session, and it is applied
82
+ // whichever scope the person holds. `v4` belongs to acc-b.
83
+ const own = await processVisits('sess', env(db(ROWS, asPerson('alice', 'own'))), () => {}, 1)
84
+ const acct = await processVisits('sess', env(db(ROWS, asPerson('marie', 'account'))), () => {}, 1)
85
+ expect(ids(own)).not.toContain('v4')
86
+ expect(ids(acct)).not.toContain('v4')
87
+ })
88
+
89
+ it('hides an unbound visit from an own-scoped person', async () => {
90
+ // An unbound visit carries ownerId ''. Enrolment pins ownerId to
91
+ // ^[a-z0-9-]+$ (portal-enrol.mjs), so no enrolled person's id is ever '',
92
+ // and the own-scoped filter cannot match the unbound sentinel.
93
+ const h = await processVisits('sess', env(db(ROWS, asPerson('alice', 'own'))), () => {}, 1)
94
+ expect(ids(h)).not.toContain('v3')
95
+ })
96
+
97
+ it('denies when the session does not resolve', async () => {
98
+ const d = {
99
+ prepare: () => ({ bind: () => ({ first: async () => null }) }),
100
+ } as unknown as D1Database
101
+ const h = await processVisits('', env(d), () => {}, 1)
102
+ expect(h.status).toBe(401)
103
+ expect(h.payload).toEqual({ ok: false, error: 'denied' })
104
+ })
105
+
106
+ it('logs the scope it applied, and no visit detail', async () => {
107
+ const lines: string[] = []
108
+ await processVisits('sess', env(db(ROWS, asPerson('alice', 'own'))), (l) => lines.push(l), 1)
109
+ expect(lines.join('\n')).toContain('scope=own')
110
+ expect(lines.join('\n')).toContain('visits=1')
111
+ })
112
+ })