@rubytech/create-maxy-code 0.1.561 → 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 (213) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/hooks/__tests__/datetime-inject.test.sh +10 -0
  3. package/payload/platform/plugins/admin/hooks/__tests__/mailbox-inject.test.sh +15 -2
  4. package/payload/platform/plugins/admin/hooks/__tests__/preference-consult-directive.test.sh +6 -0
  5. package/payload/platform/plugins/admin/hooks/__tests__/prompt-optimiser-directive.test.sh +10 -0
  6. package/payload/platform/plugins/admin/hooks/datetime-inject.sh +10 -0
  7. package/payload/platform/plugins/admin/hooks/mailbox-inject.sh +14 -0
  8. package/payload/platform/plugins/admin/hooks/preference-consult-directive.sh +9 -0
  9. package/payload/platform/plugins/admin/hooks/prompt-optimiser-directive.sh +10 -0
  10. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +21 -11
  11. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +11 -0
  12. package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +19 -5
  13. package/payload/platform/plugins/cloudflare/mcp/__tests__/authorize.test.ts +6 -0
  14. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +77 -6
  15. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-session-grant.test.ts +39 -0
  16. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-visit-status-write.test.ts +176 -0
  17. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-visits-scope.test.ts +112 -0
  18. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +23 -0
  19. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +94 -0
  20. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/session.ts +31 -4
  21. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/visit-status.ts +136 -0
  22. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/visits.ts +93 -0
  23. package/payload/platform/plugins/docs/references/deployment.md +14 -8
  24. package/payload/platform/plugins/docs/references/internals.md +2 -0
  25. package/payload/platform/plugins/docs/references/platform.md +4 -2
  26. package/payload/platform/scripts/__tests__/public-prompt-gate-registered.test.sh +114 -0
  27. package/payload/platform/scripts/smoke-boot-services.sh +14 -0
  28. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  29. package/payload/platform/services/claude-session-manager/dist/http-server.js +26 -10
  30. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  31. package/payload/platform/services/claude-session-manager/dist/index.js +16 -0
  32. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  33. package/payload/platform/services/claude-session-manager/dist/public-prompt-surface.d.ts +56 -0
  34. package/payload/platform/services/claude-session-manager/dist/public-prompt-surface.d.ts.map +1 -0
  35. package/payload/platform/services/claude-session-manager/dist/public-prompt-surface.js +155 -0
  36. package/payload/platform/services/claude-session-manager/dist/public-prompt-surface.js.map +1 -0
  37. package/payload/platform/services/claude-session-manager/dist/rc-public-lockdown.d.ts +6 -0
  38. package/payload/platform/services/claude-session-manager/dist/rc-public-lockdown.d.ts.map +1 -1
  39. package/payload/platform/services/claude-session-manager/dist/rc-public-lockdown.js +1 -1
  40. package/payload/platform/services/claude-session-manager/dist/rc-public-lockdown.js.map +1 -1
  41. package/payload/platform/services/claude-session-manager/dist/system-prompt.d.ts +4 -0
  42. package/payload/platform/services/claude-session-manager/dist/system-prompt.d.ts.map +1 -1
  43. package/payload/platform/services/claude-session-manager/dist/system-prompt.js +45 -27
  44. package/payload/platform/services/claude-session-manager/dist/system-prompt.js.map +1 -1
  45. package/payload/platform/templates/agents/public/IDENTITY.md +3 -3
  46. package/payload/server/maxy-edge.js +302 -33
  47. package/payload/server/public/activity.html +5 -5
  48. package/payload/server/public/agents.html +4 -4
  49. package/payload/server/public/assets/{AdminLoginScreens-DLogIsJT.js → AdminLoginScreens-C6e5jf0S.js} +1 -1
  50. package/payload/server/public/assets/AdminLoginScreens-C6e5jf0S.js.br +0 -0
  51. package/payload/server/public/assets/AdminLoginScreens-C6e5jf0S.js.gz +0 -0
  52. package/payload/server/public/assets/AdminShell-DStA0lfm.js +3 -0
  53. package/payload/server/public/assets/AdminShell-DStA0lfm.js.br +0 -0
  54. package/payload/server/public/assets/AdminShell-DStA0lfm.js.gz +0 -0
  55. package/payload/server/public/assets/{activity-AVJSFESV.js → activity--yyBcNfB.js} +1 -1
  56. package/payload/server/public/assets/activity--yyBcNfB.js.br +0 -0
  57. package/payload/server/public/assets/activity--yyBcNfB.js.gz +0 -0
  58. package/payload/server/public/assets/{admin-eFgbld42.js → admin-Dr6Jwe7W.js} +1 -1
  59. package/payload/server/public/assets/admin-Dr6Jwe7W.js.br +0 -0
  60. package/payload/server/public/assets/admin-Dr6Jwe7W.js.gz +0 -0
  61. package/payload/server/public/assets/{agents-CoWLZMf7.js → agents-odyVOb5E.js} +1 -1
  62. package/payload/server/public/assets/agents-odyVOb5E.js.br +0 -0
  63. package/payload/server/public/assets/agents-odyVOb5E.js.gz +0 -0
  64. package/payload/server/public/assets/{browser-C6K66_fw.js → browser-D1K_MoHt.js} +1 -1
  65. package/payload/server/public/assets/browser-D1K_MoHt.js.br +0 -0
  66. package/payload/server/public/assets/browser-D1K_MoHt.js.gz +0 -0
  67. package/payload/server/public/assets/{calendar-Dnw_cP4b.js → calendar-jqh9U7Ic.js} +1 -1
  68. package/payload/server/public/assets/calendar-jqh9U7Ic.js.br +0 -0
  69. package/payload/server/public/assets/calendar-jqh9U7Ic.js.gz +0 -0
  70. package/payload/server/public/assets/chat-QQHBdVor.js +1 -0
  71. package/payload/server/public/assets/chat-QQHBdVor.js.br +0 -0
  72. package/payload/server/public/assets/chat-QQHBdVor.js.gz +0 -0
  73. package/payload/server/public/assets/chevron-left-DwTXgKwr.js +1 -0
  74. package/payload/server/public/assets/clock-DOmh9iKX.js +1 -0
  75. package/payload/server/public/assets/clock-DOmh9iKX.js.br +0 -0
  76. package/payload/server/public/assets/{copy-kuNM0Etw.js → copy-DkuHwkaB.js} +1 -1
  77. package/payload/server/public/assets/copy-DkuHwkaB.js.br +0 -0
  78. package/payload/server/public/assets/copy-DkuHwkaB.js.gz +0 -0
  79. package/payload/server/public/assets/data-DYmTNqrb.js +1 -0
  80. package/payload/server/public/assets/data-DYmTNqrb.js.br +0 -0
  81. package/payload/server/public/assets/data-DYmTNqrb.js.gz +0 -0
  82. package/payload/server/public/assets/{file-text-BWkobrcx.js → file-text-C6o_NRjj.js} +1 -1
  83. package/payload/server/public/assets/file-text-C6o_NRjj.js.br +0 -0
  84. package/payload/server/public/assets/file-text-C6o_NRjj.js.gz +0 -0
  85. package/payload/server/public/assets/{graph-BGjTL5Vz.js → graph-BN30v5hJ.js} +1 -1
  86. package/payload/server/public/assets/graph-BN30v5hJ.js.br +0 -0
  87. package/payload/server/public/assets/graph-BN30v5hJ.js.gz +0 -0
  88. package/payload/server/public/assets/{graph-labels-Dvc5Kw_r.js → graph-labels-DHFyNXQt.js} +1 -1
  89. package/payload/server/public/assets/graph-labels-DHFyNXQt.js.br +0 -0
  90. package/payload/server/public/assets/graph-labels-DHFyNXQt.js.gz +0 -0
  91. package/payload/server/public/assets/{operator-CzBGv_he.js → operator-DwFYA4Fp.js} +1 -1
  92. package/payload/server/public/assets/operator-DwFYA4Fp.js.br +0 -0
  93. package/payload/server/public/assets/operator-DwFYA4Fp.js.gz +0 -0
  94. package/payload/server/public/assets/{page-DaySqamH.js → page-BALVRssF.js} +1 -1
  95. package/payload/server/public/assets/page-BALVRssF.js.br +0 -0
  96. package/payload/server/public/assets/page-BALVRssF.js.gz +0 -0
  97. package/payload/server/public/assets/{page-BIY4Ku8Q.js → page-CEWIP3VF.js} +1 -1
  98. package/payload/server/public/assets/page-CEWIP3VF.js.br +0 -0
  99. package/payload/server/public/assets/page-CEWIP3VF.js.gz +0 -0
  100. package/payload/server/public/assets/play-Bg-BY_oj.js +1 -0
  101. package/payload/server/public/assets/play-Bg-BY_oj.js.br +0 -0
  102. package/payload/server/public/assets/play-Bg-BY_oj.js.gz +0 -0
  103. package/payload/server/public/assets/{public-pwDSvhBv.js → public-Ceb0n7Km.js} +1 -1
  104. package/payload/server/public/assets/public-Ceb0n7Km.js.br +0 -0
  105. package/payload/server/public/assets/public-Ceb0n7Km.js.gz +0 -0
  106. package/payload/server/public/assets/{rotate-ccw-B6s7tkEt.js → rotate-ccw-CDYdx91V.js} +1 -1
  107. package/payload/server/public/assets/rotate-ccw-CDYdx91V.js.gz +0 -0
  108. package/payload/server/public/assets/{routines-CFlyRdYg.js → routines-DwNTih0y.js} +1 -1
  109. package/payload/server/public/assets/routines-DwNTih0y.js.br +0 -0
  110. package/payload/server/public/assets/routines-DwNTih0y.js.gz +0 -0
  111. package/payload/server/public/assets/{skills-C45kfL_Q.js → skills-TIDCdOLQ.js} +1 -1
  112. package/payload/server/public/assets/skills-TIDCdOLQ.js.br +0 -0
  113. package/payload/server/public/assets/skills-TIDCdOLQ.js.gz +0 -0
  114. package/payload/server/public/assets/{tasks-BwoCTCu5.js → tasks-eTXvaWnd.js} +1 -1
  115. package/payload/server/public/assets/tasks-eTXvaWnd.js.br +0 -0
  116. package/payload/server/public/assets/tasks-eTXvaWnd.js.gz +0 -0
  117. package/payload/server/public/assets/{triangle-alert-B3bthSzx.js → triangle-alert-JwLSrj17.js} +1 -1
  118. package/payload/server/public/assets/triangle-alert-JwLSrj17.js.br +0 -0
  119. package/payload/server/public/assets/triangle-alert-JwLSrj17.js.gz +0 -0
  120. package/payload/server/public/assets/{useCopyFeedback-BzJAkFEb.js → useCopyFeedback-DjTkNw2p.js} +1 -1
  121. package/payload/server/public/assets/useCopyFeedback-DjTkNw2p.js.br +0 -0
  122. package/payload/server/public/assets/useCopyFeedback-DjTkNw2p.js.gz +0 -0
  123. package/payload/server/public/assets/useMediaQuery-BsBzbWaf.css +1 -0
  124. package/payload/server/public/assets/useMediaQuery-BsBzbWaf.css.br +0 -0
  125. package/payload/server/public/assets/useMediaQuery-BsBzbWaf.css.gz +0 -0
  126. package/payload/server/public/assets/useMediaQuery-D8VDA0RN.js +14 -0
  127. package/payload/server/public/assets/useMediaQuery-D8VDA0RN.js.br +0 -0
  128. package/payload/server/public/assets/useMediaQuery-D8VDA0RN.js.gz +0 -0
  129. package/payload/server/public/assets/{useVoiceRecorder-_qI_BAOo.js → useVoiceRecorder-B8vxObZG.js} +1 -1
  130. package/payload/server/public/assets/useVoiceRecorder-B8vxObZG.js.br +0 -0
  131. package/payload/server/public/assets/useVoiceRecorder-B8vxObZG.js.gz +0 -0
  132. package/payload/server/public/assets/{wrench-Bqux9jaw.js → wrench-DutUiRu7.js} +1 -1
  133. package/payload/server/public/assets/wrench-DutUiRu7.js.br +0 -0
  134. package/payload/server/public/assets/wrench-DutUiRu7.js.gz +0 -0
  135. package/payload/server/public/browser.html +4 -4
  136. package/payload/server/public/calendar.html +7 -7
  137. package/payload/server/public/chat.html +13 -13
  138. package/payload/server/public/data.html +11 -11
  139. package/payload/server/public/graph.html +9 -9
  140. package/payload/server/public/index.html +13 -13
  141. package/payload/server/public/operator.html +14 -14
  142. package/payload/server/public/public.html +13 -13
  143. package/payload/server/public/routines.html +7 -7
  144. package/payload/server/public/skills.html +5 -5
  145. package/payload/server/public/tasks.html +6 -6
  146. package/payload/server/server.js +1118 -636
  147. package/payload/server/public/assets/AdminLoginScreens-DLogIsJT.js.br +0 -0
  148. package/payload/server/public/assets/AdminLoginScreens-DLogIsJT.js.gz +0 -0
  149. package/payload/server/public/assets/AdminShell-B67tPTyE.js +0 -2
  150. package/payload/server/public/assets/AdminShell-B67tPTyE.js.br +0 -0
  151. package/payload/server/public/assets/AdminShell-B67tPTyE.js.gz +0 -0
  152. package/payload/server/public/assets/activity-AVJSFESV.js.br +0 -0
  153. package/payload/server/public/assets/activity-AVJSFESV.js.gz +0 -0
  154. package/payload/server/public/assets/admin-eFgbld42.js.br +0 -0
  155. package/payload/server/public/assets/admin-eFgbld42.js.gz +0 -0
  156. package/payload/server/public/assets/agents-CoWLZMf7.js.br +0 -0
  157. package/payload/server/public/assets/agents-CoWLZMf7.js.gz +0 -0
  158. package/payload/server/public/assets/browser-C6K66_fw.js.br +0 -0
  159. package/payload/server/public/assets/browser-C6K66_fw.js.gz +0 -0
  160. package/payload/server/public/assets/calendar-Dnw_cP4b.js.br +0 -0
  161. package/payload/server/public/assets/calendar-Dnw_cP4b.js.gz +0 -0
  162. package/payload/server/public/assets/chat-Cm0fkpg1.js +0 -1
  163. package/payload/server/public/assets/chat-Cm0fkpg1.js.br +0 -0
  164. package/payload/server/public/assets/chat-Cm0fkpg1.js.gz +0 -0
  165. package/payload/server/public/assets/chevron-left-CjRH-BtF.js +0 -1
  166. package/payload/server/public/assets/chevron-left-CjRH-BtF.js.br +0 -0
  167. package/payload/server/public/assets/clock-wbYEt1kM.js +0 -1
  168. package/payload/server/public/assets/clock-wbYEt1kM.js.br +0 -0
  169. package/payload/server/public/assets/clock-wbYEt1kM.js.gz +0 -0
  170. package/payload/server/public/assets/copy-kuNM0Etw.js.br +0 -0
  171. package/payload/server/public/assets/copy-kuNM0Etw.js.gz +0 -0
  172. package/payload/server/public/assets/data-c47T2NCd.js +0 -1
  173. package/payload/server/public/assets/data-c47T2NCd.js.br +0 -1
  174. package/payload/server/public/assets/data-c47T2NCd.js.gz +0 -0
  175. package/payload/server/public/assets/file-text-BWkobrcx.js.br +0 -0
  176. package/payload/server/public/assets/file-text-BWkobrcx.js.gz +0 -0
  177. package/payload/server/public/assets/graph-BGjTL5Vz.js.br +0 -0
  178. package/payload/server/public/assets/graph-BGjTL5Vz.js.gz +0 -0
  179. package/payload/server/public/assets/graph-labels-Dvc5Kw_r.js.br +0 -0
  180. package/payload/server/public/assets/graph-labels-Dvc5Kw_r.js.gz +0 -0
  181. package/payload/server/public/assets/operator-CzBGv_he.js.br +0 -0
  182. package/payload/server/public/assets/operator-CzBGv_he.js.gz +0 -0
  183. package/payload/server/public/assets/page-BIY4Ku8Q.js.br +0 -0
  184. package/payload/server/public/assets/page-BIY4Ku8Q.js.gz +0 -0
  185. package/payload/server/public/assets/page-DaySqamH.js.br +0 -0
  186. package/payload/server/public/assets/page-DaySqamH.js.gz +0 -0
  187. package/payload/server/public/assets/play-C8RA52_i.js +0 -1
  188. package/payload/server/public/assets/play-C8RA52_i.js.br +0 -0
  189. package/payload/server/public/assets/play-C8RA52_i.js.gz +0 -0
  190. package/payload/server/public/assets/public-pwDSvhBv.js.br +0 -0
  191. package/payload/server/public/assets/public-pwDSvhBv.js.gz +0 -0
  192. package/payload/server/public/assets/rotate-ccw-B6s7tkEt.js.br +0 -0
  193. package/payload/server/public/assets/rotate-ccw-B6s7tkEt.js.gz +0 -0
  194. package/payload/server/public/assets/routines-CFlyRdYg.js.br +0 -0
  195. package/payload/server/public/assets/routines-CFlyRdYg.js.gz +0 -0
  196. package/payload/server/public/assets/skills-C45kfL_Q.js.br +0 -0
  197. package/payload/server/public/assets/skills-C45kfL_Q.js.gz +0 -0
  198. package/payload/server/public/assets/tasks-BwoCTCu5.js.br +0 -0
  199. package/payload/server/public/assets/tasks-BwoCTCu5.js.gz +0 -0
  200. package/payload/server/public/assets/triangle-alert-B3bthSzx.js.br +0 -0
  201. package/payload/server/public/assets/triangle-alert-B3bthSzx.js.gz +0 -0
  202. package/payload/server/public/assets/useCopyFeedback-BzJAkFEb.js.br +0 -0
  203. package/payload/server/public/assets/useCopyFeedback-BzJAkFEb.js.gz +0 -0
  204. package/payload/server/public/assets/useMediaQuery-DJ6XeNN7.js +0 -14
  205. package/payload/server/public/assets/useMediaQuery-DJ6XeNN7.js.br +0 -0
  206. package/payload/server/public/assets/useMediaQuery-DJ6XeNN7.js.gz +0 -0
  207. package/payload/server/public/assets/useMediaQuery-DW_xRrMl.css +0 -1
  208. package/payload/server/public/assets/useMediaQuery-DW_xRrMl.css.br +0 -0
  209. package/payload/server/public/assets/useMediaQuery-DW_xRrMl.css.gz +0 -0
  210. package/payload/server/public/assets/useVoiceRecorder-_qI_BAOo.js.br +0 -0
  211. package/payload/server/public/assets/useVoiceRecorder-_qI_BAOo.js.gz +0 -0
  212. package/payload/server/public/assets/wrench-Bqux9jaw.js.br +0 -0
  213. 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.561",
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"
@@ -114,6 +114,16 @@ else
114
114
  fi
115
115
  rm -rf "$FAKEBIN2"
116
116
 
117
+ # Task 2424 — a public visitor's turn carries only its agent's authored
118
+ # IDENTITY/SOUL/KNOWLEDGE. MAXY_PUBLIC_ALLOWED_TOOLS is stamped for role=public
119
+ # spawns only, by both spawn paths.
120
+ pub_out=$(printf '%s' '{"session_id":"x","prompt":"hi"}' | MAXY_PUBLIC_ALLOWED_TOOLS='mcp__channel__reply' bash "$HOOK" 2>/dev/null); pub_rc=$?
121
+ if [[ "$pub_rc" -eq 0 && -z "$pub_out" ]]; then
122
+ echo "PASS: public turn exits 0 with no stdout"; PASS=$((PASS+1))
123
+ else
124
+ echo "FAIL: public turn injected (exit=$pub_rc stdout=$pub_out)" >&2; FAIL=$((FAIL+1))
125
+ fi
126
+
117
127
  echo "----"
118
128
  echo "PASS=$PASS FAIL=$FAIL"
119
129
  [[ "$FAIL" -eq 0 ]]
@@ -493,13 +493,26 @@ else
493
493
  fi
494
494
  rm -rf "$A"
495
495
 
496
+ # Task 2424 — a public visitor's turn must never carry the operator's mailbox
497
+ # list. This hook's only role gate was "is ACCOUNT_DIR set", which held on the
498
+ # pty-spawner path but not on the rc-spawn path, where buildRcChildEnv stamps it
499
+ # unconditionally. MAXY_PUBLIC_ALLOWED_TOOLS is stamped for role=public only.
500
+ PUB_A=$(mktemp -d)
501
+ PUB_OUT=$(printf '%s' '{"session_id":"x","prompt":"hi"}' | ACCOUNT_DIR="$PUB_A" MAXY_PUBLIC_ALLOWED_TOOLS='mcp__channel__reply' bash "$HOOK" 2>/dev/null); PUB_RC=$?
502
+ if [ "$PUB_RC" -eq 0 ] && [ -z "$PUB_OUT" ]; then
503
+ ok "public turn exits 0 with no stdout even with ACCOUNT_DIR set"
504
+ else
505
+ bad "public turn injected (exit=$PUB_RC stdout=$PUB_OUT)"
506
+ fi
507
+ rm -rf "$PUB_A"
508
+
496
509
  echo "----"
497
510
  echo "PASS=$PASS FAIL=$FAIL"
498
511
  # The count is asserted, not just the failures: a case that stops running (a
499
512
  # broken helper, a guard, an early exit) otherwise leaves a green suite that
500
513
  # tested less than it claims, and .husky/pre-commit reads only the exit status.
501
- EXPECTED=28
502
- if [ "$(id -u)" -eq 0 ]; then EXPECTED=26; fi
514
+ EXPECTED=29
515
+ if [ "$(id -u)" -eq 0 ]; then EXPECTED=27; fi
503
516
  if [[ $((PASS+FAIL)) -ne "$EXPECTED" ]]; then
504
517
  echo "FAIL: expected $EXPECTED cases, ran $((PASS+FAIL))" >&2
505
518
  exit 1
@@ -10,6 +10,12 @@ check 'printf "%s" "$OUT" | grep -q "hookSpecificOutput"' "emits envelope"
10
10
  check 'printf "%s" "$OUT" | grep -q "profile-read"' "names profile-read"
11
11
  check 'printf "%s" "$OUT" | grep -q "output/"' "names output scratch rule"
12
12
  check '! printf "%s" "$OUT" | grep -q "—"' "no em-dash"
13
+ # Task 2424 — a public visitor's turn carries only its agent's authored
14
+ # IDENTITY/SOUL/KNOWLEDGE. MAXY_PUBLIC_ALLOWED_TOOLS is stamped for role=public
15
+ # spawns only, by both spawn paths.
16
+ OUT_PUB=$(printf '{"session_id":"x","prompt":"hi"}' | MAXY_PUBLIC_ALLOWED_TOOLS='mcp__channel__reply' bash "$HOOK" 2>/dev/null); RC_PUB=$?
17
+ check '[ "$RC_PUB" = "0" ]' "public turn exits 0"
18
+ check '[ -z "$OUT_PUB" ]' "public turn emits nothing on stdout"
13
19
  echo "----- $PASS passed, $FAIL failed -----"
14
20
  for f in "${FAILED[@]:-}"; do [ -n "$f" ] && echo " $f"; done
15
21
  [ "$FAIL" -eq 0 ]
@@ -195,6 +195,16 @@ else
195
195
  echo "FAIL: false-suppression on a normal prompt (exit=$norm_exit stdout=$norm_out)" >&2; FAIL=$((FAIL+1))
196
196
  fi
197
197
 
198
+ # Task 2424 — a public visitor's turn carries only its agent's authored
199
+ # IDENTITY/SOUL/KNOWLEDGE. MAXY_PUBLIC_ALLOWED_TOOLS is stamped for role=public
200
+ # spawns only, by both spawn paths.
201
+ pub_out=$(printf '%s' '{"session_id":"x","prompt":"hi"}' | MAXY_PUBLIC_ALLOWED_TOOLS='mcp__channel__reply' bash "$HOOK" 2>/dev/null); pub_rc=$?
202
+ if [[ "$pub_rc" -eq 0 && -z "$pub_out" ]]; then
203
+ echo "PASS: public turn exits 0 with no stdout"; PASS=$((PASS+1))
204
+ else
205
+ echo "FAIL: public turn injected (exit=$pub_rc stdout=$pub_out)" >&2; FAIL=$((FAIL+1))
206
+ fi
207
+
198
208
  echo "----"
199
209
  echo "PASS=$PASS FAIL=$FAIL"
200
210
  [[ "$FAIL" -eq 0 ]]
@@ -34,6 +34,16 @@ set -uo pipefail
34
34
  # session_id is used here (for the breadcrumb); the block is prompt-independent.
35
35
  HOOK_INPUT=$(cat 2>/dev/null || true)
36
36
 
37
+ # Task 2424 — a public visitor's turn carries only its agent's authored
38
+ # IDENTITY/SOUL/KNOWLEDGE. MAXY_PUBLIC_ALLOWED_TOOLS is stamped for role=public
39
+ # spawns only, by both spawn paths, and is the signal public-tool-surface.sh
40
+ # already keys on. Placed after the stdin drain so the parent never sees EPIPE.
41
+ if [ -n "${MAXY_PUBLIC_ALLOWED_TOOLS:-}" ]; then
42
+ echo "[public-prompt-gate] op=gate hook=datetime-inject.sh public=1" >&2
43
+ exit 0
44
+ fi
45
+ echo "[public-prompt-gate] op=gate hook=datetime-inject.sh public=0" >&2
46
+
37
47
  # Fail-open if either encoder is unavailable: python3 wraps the JSON envelope,
38
48
  # node formats the instant in the target zone (Intl has the full IANA database).
39
49
  command -v python3 >/dev/null 2>&1 || exit 0
@@ -32,6 +32,20 @@ set -uo pipefail
32
32
  # hit with EPIPE. Same reason the two sibling hooks drain first.
33
33
  HOOK_INPUT=$(cat 2>/dev/null || true)
34
34
 
35
+ # Task 2424 — a public visitor's turn carries only its agent's authored
36
+ # IDENTITY/SOUL/KNOWLEDGE. The ACCOUNT_DIR test below is NOT a role gate on the
37
+ # rc-spawn path: buildRcChildEnv stamps that variable unconditionally
38
+ # (rc-daemon.ts), so Task 2271's reasoning held only for pty-spawner, and after
39
+ # Task 2423 routed public Telegram through /rc-spawn the operator's mailbox list
40
+ # reached visitors. MAXY_PUBLIC_ALLOWED_TOOLS is stamped for role=public spawns
41
+ # only, by both paths. Placed after the stdin drain so the parent never sees
42
+ # EPIPE.
43
+ if [ -n "${MAXY_PUBLIC_ALLOWED_TOOLS:-}" ]; then
44
+ echo "[public-prompt-gate] op=gate hook=mailbox-inject.sh public=1" >&2
45
+ exit 0
46
+ fi
47
+ echo "[public-prompt-gate] op=gate hook=mailbox-inject.sh public=0" >&2
48
+
35
49
  command -v python3 >/dev/null 2>&1 || exit 0
36
50
  command -v node >/dev/null 2>&1 || exit 0
37
51
 
@@ -5,6 +5,15 @@
5
5
  # promote finished deliverables out of output/. Prompt-independent; fail-open.
6
6
  set -uo pipefail
7
7
  HOOK_INPUT=$(cat 2>/dev/null || true)
8
+ # Task 2424 — a public visitor's turn carries only its agent's authored
9
+ # IDENTITY/SOUL/KNOWLEDGE. MAXY_PUBLIC_ALLOWED_TOOLS is stamped for role=public
10
+ # spawns only, by both spawn paths, and is the signal public-tool-surface.sh
11
+ # already keys on. Placed after the stdin drain so the parent never sees EPIPE.
12
+ if [ -n "${MAXY_PUBLIC_ALLOWED_TOOLS:-}" ]; then
13
+ echo "[public-prompt-gate] op=gate hook=preference-consult-directive.sh public=1" >&2
14
+ exit 0
15
+ fi
16
+ echo "[public-prompt-gate] op=gate hook=preference-consult-directive.sh public=0" >&2
8
17
  command -v python3 >/dev/null 2>&1 || exit 0
9
18
  python3 - <<'PY' 2>/dev/null || exit 0
10
19
  import json
@@ -38,6 +38,16 @@ set -uo pipefail
38
38
  # UserPromptSubmit payload carries session_id, used for the durable breadcrumb.
39
39
  HOOK_INPUT=$(cat 2>/dev/null || true)
40
40
 
41
+ # Task 2424 — a public visitor's turn carries only its agent's authored
42
+ # IDENTITY/SOUL/KNOWLEDGE. MAXY_PUBLIC_ALLOWED_TOOLS is stamped for role=public
43
+ # spawns only, by both spawn paths, and is the signal public-tool-surface.sh
44
+ # already keys on. Placed after the stdin drain so the parent never sees EPIPE.
45
+ if [ -n "${MAXY_PUBLIC_ALLOWED_TOOLS:-}" ]; then
46
+ echo "[public-prompt-gate] op=gate hook=prompt-optimiser-directive.sh public=1" >&2
47
+ exit 0
48
+ fi
49
+ echo "[public-prompt-gate] op=gate hook=prompt-optimiser-directive.sh public=0" >&2
50
+
41
51
  # Fail-open if the JSON encoder is unavailable.
42
52
  command -v python3 >/dev/null 2>&1 || exit 0
43
53
 
@@ -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:0a9fdc97ea6caba4625dfee58ef5c504befcd1adfef8c892330bf95b3c17c3ee
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.
@@ -5557,6 +5559,8 @@ There is exactly one place each datum is read from, and every subsystem reads it
5557
5559
  - **Default agent + account id** come from the canonical account config `data/accounts/<id>/account.json`, resolved via `resolveAccount` / `resolveDefaultAgentSlug` / `getDefaultAccountId` (`app/lib/claude-agent/account.ts`). The UI server's bare-root handler and branding loader read it through these helpers — never from `~/.<brand>/account.json`, which nothing writes. A `null` default means a genuinely unset `defaultAgent`; the root logs `[public-root] op=serve-default slug=<s>` on success and `op=no-default` only when the canonical default is truly empty.
5558
5560
  - **Identity files** (IDENTITY.md, SOUL.md, KNOWLEDGE.md) come from `agents/<slug>/` — keyed on the agent's slug, never the literal role. A public agent renamed to a slug `≠ "public"` (e.g. `maxy-info`) spawns from `agents/maxy-info/`. Admin reads `agents/admin/`. The spawn composer and the standing reachability audit resolve this directory through one shared function, `resolveAgentIdentityDir(accountDir, role, slug)` (`claude-session-manager/src/agent-identity-locator.ts`), so the path the audit vouches for is byte-identical to the path the spawn reads — the audit's `reachable=true` implies the spawn will find the files, and a slug whose files are misplaced is reported `reachable=false reason=files-missing file=<path>` without a visitor request.
5559
5561
 
5562
+ **What a public block contains.** A `role='public'` compose emits `<identity>`, `<soul>`, `<platform-boundary>`, `<data-stewardship>` and `<knowledge>`, and nothing else. The `<host>` section every other role gets is omitted: hostname, LAN IPv4, admin URL and tunnel URL are facts about the operator's machine, and a visitor's agent holds only what its author wrote. The two standing directives stay because they constrain behaviour rather than stating extractable facts. `op=public-persona-composed … hostBytes=0` is the per-spawn proof; non-zero on a public spawn is the regression signal, readable without reproducing a visitor turn.
5563
+
5560
5564
  Which account is `<id>`? The slug is the address, and the address names the account. `resolvePublicAddressFromDisk` (`platform/ui/app/lib/claude-agent/public-address.ts`) walks every valid account for an active agent at the requested slug: exactly one claimant serves; the house serves when it is among several claimants and the others are refused by name; two or more non-house claimants serve nobody. The bare root `/` carries no slug and stays the house's door with the house's `defaultAgent`. The four public surfaces — the `/{slug}` page, `POST /api/chat`, `POST /api/session` and the visitor transcript reader — share that one resolver, and the resolved account rides `agentAccountId` to `/public-spawn`, which re-scopes the spawn onto it. A visitor's uploads follow the same account, and the reader locates them from the session's own sidecar `accountId` rather than re-deriving them from the slug, because a slug's owner can change after the session ran. Slugs are not unique across accounts, so a collision is reported on every refusal (`[webchat] op=address-collision`) and counted on every run of the `webchat-address-audit` loop.
5561
5565
 
5562
5566
  ### Observability
@@ -6108,11 +6112,11 @@ The logs will show which service failed to start and why. Common causes:
6108
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):
6109
6113
 
6110
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).
6111
- - `{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.
6112
6116
 
6113
- 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.
6114
6118
 
6115
- 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.
6116
6120
 
6117
6121
  ## Linux laptops: snap-confined Chromium replacement
6118
6122
 
@@ -6221,17 +6225,23 @@ Empty output from step 3 = brand.json resolved cleanly and the badge reflects th
6221
6225
 
6222
6226
  ## Upgrading
6223
6227
 
6224
- 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.
6225
6231
 
6226
6232
  The docs plugin (this plugin) is upgraded in the same step — you always have the documentation that matches your installed version.
6227
6233
 
6228
- ### 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.
6229
6239
 
6230
- 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.
6231
6241
 
6232
- 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.
6233
6243
 
6234
- 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.
6235
6245
 
6236
6246
  ---
6237
6247
  # Samba Share
@@ -9,6 +9,17 @@ 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
+
18
+ ## 2026-08-05 (0.1.562)
19
+
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.
21
+ - Two standing checks back that up: one records, for every account, exactly what gets added to a turn, and the other refuses to start if that picture drifts from what is expected. Each spawn also reports how much was added, so a public one showing anything other than nothing is visible immediately.
22
+
12
23
  ## 2026-08-04 (0.1.561)
13
24
 
14
25
  - A public Telegram bot now answers. Messages were arriving and the conversation staying alive, but nothing the visitor sent ever became a turn, so a public bot received everything and replied to nothing. Public bots now open through the same locked-down path the platform already uses elsewhere: the ordinary setup with only the reply tool available.
@@ -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)