@rubytech/create-maxy-code 0.1.460 → 0.1.462

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 (202) hide show
  1. package/dist/__tests__/cron-heartbeat-registration.test.js +57 -28
  2. package/dist/__tests__/launchd-plist.test.js +26 -0
  3. package/dist/cron-registration.js +30 -8
  4. package/dist/index.js +50 -9
  5. package/dist/launchd-plist.js +1 -1
  6. package/dist/uninstall.js +9 -3
  7. package/package.json +1 -1
  8. package/payload/platform/lib/graph-mcp/dist/index.d.ts +2 -1
  9. package/payload/platform/lib/graph-mcp/dist/index.d.ts.map +1 -1
  10. package/payload/platform/lib/graph-mcp/dist/index.js +14 -22
  11. package/payload/platform/lib/graph-mcp/dist/index.js.map +1 -1
  12. package/payload/platform/lib/graph-mcp/src/index.ts +15 -22
  13. package/payload/platform/lib/mcp-spawn-tee/dist/index.d.ts +8 -7
  14. package/payload/platform/lib/mcp-spawn-tee/dist/index.d.ts.map +1 -1
  15. package/payload/platform/lib/mcp-spawn-tee/dist/index.js +19 -19
  16. package/payload/platform/lib/mcp-spawn-tee/dist/index.js.map +1 -1
  17. package/payload/platform/lib/mcp-spawn-tee/src/__tests__/spawn-tee.test.ts +34 -5
  18. package/payload/platform/lib/mcp-spawn-tee/src/index.ts +22 -19
  19. package/payload/platform/lib/mcp-stderr-tee/dist/index.d.ts +35 -17
  20. package/payload/platform/lib/mcp-stderr-tee/dist/index.d.ts.map +1 -1
  21. package/payload/platform/lib/mcp-stderr-tee/dist/index.js +41 -48
  22. package/payload/platform/lib/mcp-stderr-tee/dist/index.js.map +1 -1
  23. package/payload/platform/lib/mcp-stderr-tee/src/index.ts +42 -51
  24. package/payload/platform/lib/storage-broker/dist/__tests__/audit.test.js +27 -0
  25. package/payload/platform/lib/storage-broker/dist/__tests__/audit.test.js.map +1 -1
  26. package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js +49 -0
  27. package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js.map +1 -1
  28. package/payload/platform/lib/storage-broker/dist/audit.d.ts +11 -0
  29. package/payload/platform/lib/storage-broker/dist/audit.d.ts.map +1 -1
  30. package/payload/platform/lib/storage-broker/dist/audit.js +16 -0
  31. package/payload/platform/lib/storage-broker/dist/audit.js.map +1 -1
  32. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts +18 -0
  33. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts.map +1 -1
  34. package/payload/platform/lib/storage-broker/dist/cf-exec.js +77 -0
  35. package/payload/platform/lib/storage-broker/dist/cf-exec.js.map +1 -1
  36. package/payload/platform/lib/storage-broker/dist/house-scoped-token.js +3 -0
  37. package/payload/platform/lib/storage-broker/dist/house-scoped-token.js.map +1 -1
  38. package/payload/platform/lib/storage-broker/dist/registry.d.ts +1 -1
  39. package/payload/platform/lib/storage-broker/dist/registry.d.ts.map +1 -1
  40. package/payload/platform/lib/storage-broker/dist/registry.js.map +1 -1
  41. package/payload/platform/lib/storage-broker/src/__tests__/audit.test.ts +32 -1
  42. package/payload/platform/lib/storage-broker/src/__tests__/cf-exec.test.ts +61 -0
  43. package/payload/platform/lib/storage-broker/src/audit.ts +45 -0
  44. package/payload/platform/lib/storage-broker/src/cf-exec.ts +91 -0
  45. package/payload/platform/lib/storage-broker/src/house-scoped-token.ts +3 -0
  46. package/payload/platform/lib/storage-broker/src/registry.ts +5 -1
  47. package/payload/platform/plugins/admin/lib/mcp-spawn-tee/index.js +19 -19
  48. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +22 -11
  49. package/payload/platform/plugins/aeo/lib/mcp-spawn-tee/index.js +19 -19
  50. package/payload/platform/plugins/browser/lib/mcp-spawn-tee/index.js +19 -19
  51. package/payload/platform/plugins/cloudflare/PLUGIN.md +1 -1
  52. package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +96 -0
  53. package/payload/platform/plugins/cloudflare/mcp/__tests__/auth-route.test.ts +54 -40
  54. package/payload/platform/plugins/cloudflare/mcp/__tests__/passcode.test.ts +169 -39
  55. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +207 -0
  56. package/payload/platform/plugins/cloudflare/mcp/dist/index.js +5 -5
  57. package/payload/platform/plugins/cloudflare/mcp/dist/index.js.map +1 -1
  58. package/payload/platform/plugins/cloudflare/mcp/package.json +1 -1
  59. package/payload/platform/plugins/cloudflare/references/hosting-sites.md +2 -0
  60. package/payload/platform/plugins/cloudflare/skills/calendar-site/SKILL.md +1 -1
  61. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +21 -19
  62. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/passcode.mjs +193 -0
  63. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/auth.ts +18 -12
  64. package/payload/platform/plugins/cloudflare/skills/data-portal/template/schema.sql +14 -5
  65. package/payload/platform/plugins/cloudflare/skills/site-deploy/SKILL.md +2 -0
  66. package/payload/platform/plugins/connector/lib/mcp-spawn-tee/index.js +19 -19
  67. package/payload/platform/plugins/contacts/lib/mcp-spawn-tee/index.js +19 -19
  68. package/payload/platform/plugins/docs/references/admin-session.md +3 -3
  69. package/payload/platform/plugins/docs/references/cloudflare.md +2 -0
  70. package/payload/platform/plugins/docs/references/outlook-guide.md +11 -4
  71. package/payload/platform/plugins/docs/references/plugins-guide.md +8 -6
  72. package/payload/platform/plugins/email/lib/mcp-spawn-tee/index.js +19 -19
  73. package/payload/platform/plugins/filesystem/lib/mcp-spawn-tee/index.js +19 -19
  74. package/payload/platform/plugins/graph-viewer/lib/mcp-spawn-tee/index.js +19 -19
  75. package/payload/platform/plugins/memory/PLUGIN.md +1 -1
  76. package/payload/platform/plugins/memory/lib/mcp-spawn-tee/index.js +19 -19
  77. package/payload/platform/plugins/memory/mcp/dist/lib/log-ingest.js +2 -2
  78. package/payload/platform/plugins/memory/mcp/dist/lib/log-ingest.js.map +1 -1
  79. package/payload/platform/plugins/outlook/.claude-plugin/plugin.json +1 -1
  80. package/payload/platform/plugins/outlook/PLUGIN.md +2 -2
  81. package/payload/platform/plugins/outlook/lib/mcp-spawn-tee/index.js +19 -19
  82. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-threaded.test.d.ts +2 -0
  83. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-threaded.test.d.ts.map +1 -0
  84. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-threaded.test.js +238 -0
  85. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-threaded.test.js.map +1 -0
  86. package/payload/platform/plugins/outlook/mcp/dist/index.js +9 -5
  87. package/payload/platform/plugins/outlook/mcp/dist/index.js.map +1 -1
  88. package/payload/platform/plugins/outlook/mcp/dist/tools/draft.d.ts +58 -9
  89. package/payload/platform/plugins/outlook/mcp/dist/tools/draft.d.ts.map +1 -1
  90. package/payload/platform/plugins/outlook/mcp/dist/tools/draft.js +113 -19
  91. package/payload/platform/plugins/outlook/mcp/dist/tools/draft.js.map +1 -1
  92. package/payload/platform/plugins/outlook/skills/outlook/SKILL.md +5 -2
  93. package/payload/platform/plugins/quickbooks/lib/mcp-spawn-tee/index.js +19 -19
  94. package/payload/platform/plugins/replicate/lib/mcp-spawn-tee/index.js +19 -19
  95. package/payload/platform/plugins/scheduling/lib/mcp-spawn-tee/index.js +19 -19
  96. package/payload/platform/plugins/storage-broker/PLUGIN.md +9 -1
  97. package/payload/platform/plugins/storage-broker/lib/mcp-spawn-tee/index.js +19 -19
  98. package/payload/platform/plugins/storage-broker/mcp/dist/confine-path.d.ts.map +1 -1
  99. package/payload/platform/plugins/storage-broker/mcp/dist/confine-path.js +16 -5
  100. package/payload/platform/plugins/storage-broker/mcp/dist/confine-path.js.map +1 -1
  101. package/payload/platform/plugins/storage-broker/mcp/dist/index.js +49 -0
  102. package/payload/platform/plugins/storage-broker/mcp/dist/index.js.map +1 -1
  103. package/payload/platform/plugins/telegram/lib/mcp-spawn-tee/index.js +19 -19
  104. package/payload/platform/plugins/url-get/lib/mcp-spawn-tee/index.js +19 -19
  105. package/payload/platform/plugins/voice-mirror/PLUGIN.md +1 -1
  106. package/payload/platform/plugins/voice-mirror/lib/mcp-spawn-tee/index.js +19 -19
  107. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.d.ts.map +1 -1
  108. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.js +98 -9
  109. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.js.map +1 -1
  110. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-retrieve-conditioning.d.ts.map +1 -1
  111. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-retrieve-conditioning.js +54 -21
  112. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-retrieve-conditioning.js.map +1 -1
  113. package/payload/platform/plugins/voice-mirror/mcp/scripts/smoke.mjs +110 -0
  114. package/payload/platform/plugins/voice-mirror/skills/voice-mirror/SKILL.md +6 -3
  115. package/payload/platform/plugins/web-designer/PLUGIN.md +1 -1
  116. package/payload/platform/plugins/web-designer/commands/website.md +6 -0
  117. package/payload/platform/plugins/web-designer/skills/web-design/references/deploy.md +12 -0
  118. package/payload/platform/plugins/whatsapp/lib/mcp-spawn-tee/index.js +19 -19
  119. package/payload/platform/plugins/work/lib/mcp-spawn-tee/index.js +19 -19
  120. package/payload/platform/plugins/workflows/lib/mcp-spawn-tee/index.js +19 -19
  121. package/payload/platform/scripts/__tests__/logs-rotate.test.sh +260 -0
  122. package/payload/platform/scripts/__tests__/resume-tunnel.test.sh +79 -0
  123. package/payload/platform/scripts/logs-read.sh +31 -0
  124. package/payload/platform/scripts/logs-rotate.sh +207 -0
  125. package/payload/platform/scripts/resume-tunnel.sh +27 -0
  126. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
  127. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +2 -0
  128. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
  129. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  130. package/payload/platform/services/claude-session-manager/dist/http-server.js +41 -5
  131. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  132. package/payload/platform/services/claude-session-manager/dist/index.d.ts +1 -0
  133. package/payload/platform/services/claude-session-manager/dist/index.d.ts.map +1 -1
  134. package/payload/platform/services/claude-session-manager/dist/index.js +22 -0
  135. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  136. package/payload/platform/services/claude-session-manager/dist/install-start-counter.d.ts +2 -0
  137. package/payload/platform/services/claude-session-manager/dist/install-start-counter.d.ts.map +1 -0
  138. package/payload/platform/services/claude-session-manager/dist/install-start-counter.js +12 -0
  139. package/payload/platform/services/claude-session-manager/dist/install-start-counter.js.map +1 -0
  140. package/payload/platform/services/claude-session-manager/dist/pty-census.d.ts +122 -9
  141. package/payload/platform/services/claude-session-manager/dist/pty-census.d.ts.map +1 -1
  142. package/payload/platform/services/claude-session-manager/dist/pty-census.js +214 -29
  143. package/payload/platform/services/claude-session-manager/dist/pty-census.js.map +1 -1
  144. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  145. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +39 -1
  146. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  147. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts +5 -3
  148. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
  149. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +13 -1
  150. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
  151. package/payload/platform/services/claude-session-manager/dist/session-memory-cap.d.ts.map +1 -1
  152. package/payload/platform/services/claude-session-manager/dist/session-memory-cap.js +4 -2
  153. package/payload/platform/services/claude-session-manager/dist/session-memory-cap.js.map +1 -1
  154. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.d.ts +150 -0
  155. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.d.ts.map +1 -0
  156. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.js +333 -0
  157. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.js.map +1 -0
  158. package/payload/platform/services/claude-session-manager/dist/start-counter.d.ts +10 -0
  159. package/payload/platform/services/claude-session-manager/dist/start-counter.d.ts.map +1 -0
  160. package/payload/platform/services/claude-session-manager/dist/start-counter.js +55 -0
  161. package/payload/platform/services/claude-session-manager/dist/start-counter.js.map +1 -0
  162. package/payload/server/{chunk-JXWFVE5X.js → chunk-BBSPFN45.js} +65 -0
  163. package/payload/server/public/assets/{AdminLoginScreens-BFbpkEUS.js → AdminLoginScreens-Bed8SDxY.js} +1 -1
  164. package/payload/server/public/assets/{AdminShell-CdOGObaZ.js → AdminShell-BLh9O6dn.js} +1 -1
  165. package/payload/server/public/assets/{Checkbox-RFD_F0c0.js → Checkbox-BC_KSbpS.js} +1 -1
  166. package/payload/server/public/assets/{admin-CYGdL-1O.js → admin-DfWgJVI3.js} +1 -1
  167. package/payload/server/public/assets/{browser-DVfa_plj.js → browser-C0vkv-hH.js} +1 -1
  168. package/payload/server/public/assets/{calendar-JC_Dxa4n.js → calendar-_pfHw0Hp.js} +1 -1
  169. package/payload/server/public/assets/chat-CufidiBQ.js +1 -0
  170. package/payload/server/public/assets/chevron-left-BnpVBu51.js +1 -0
  171. package/payload/server/public/assets/data-CR-fbPWr.js +1 -0
  172. package/payload/server/public/assets/{graph-hBUrRSkr.js → graph-BZiudUGr.js} +1 -1
  173. package/payload/server/public/assets/{graph-labels-CHh6zs8t.js → graph-labels-BzmmJwCR.js} +1 -1
  174. package/payload/server/public/assets/{maximize-2-DjxE4rm9.js → maximize-2-Bhdsv0U-.js} +1 -1
  175. package/payload/server/public/assets/{operator-BqU9SQuu.js → operator-Cj0B_usT.js} +1 -1
  176. package/payload/server/public/assets/page-84Fdwiu_.js +32 -0
  177. package/payload/server/public/assets/{page-DiQhU6lD.js → page-8AB3jUa_.js} +1 -1
  178. package/payload/server/public/assets/{public-Cu_eALgY.js → public-BkNW9e9T.js} +1 -1
  179. package/payload/server/public/assets/{rotate-ccw-F9DsSTSJ.js → rotate-ccw-D_5cETGt.js} +1 -1
  180. package/payload/server/public/assets/{tasks-CE-D0f50.js → tasks-EyKLXINi.js} +1 -1
  181. package/payload/server/public/assets/{time-entry-format-D2rsWhov.js → time-entry-format-DAwmmR3i.js} +1 -1
  182. package/payload/server/public/assets/{useCopyFeedback-QxOmoobV.js → useCopyFeedback-rOWd_lzl.js} +1 -1
  183. package/payload/server/public/assets/{useSelectionMode-RIzNqFjV.js → useSelectionMode-DDmLmPph.js} +1 -1
  184. package/payload/server/public/assets/{useSubAccountSwitcher-BW_fK1Nt.css → useSubAccountSwitcher-pu0jjLCz.css} +1 -1
  185. package/payload/server/public/assets/{useVoiceRecorder-WrWurSYI.js → useVoiceRecorder-azG8zSUI.js} +1 -1
  186. package/payload/server/public/browser.html +5 -5
  187. package/payload/server/public/calendar.html +6 -6
  188. package/payload/server/public/chat.html +12 -12
  189. package/payload/server/public/data.html +10 -10
  190. package/payload/server/public/graph.html +11 -11
  191. package/payload/server/public/index.html +13 -13
  192. package/payload/server/public/operator.html +14 -14
  193. package/payload/server/public/public.html +12 -12
  194. package/payload/server/public/tasks.html +5 -5
  195. package/payload/server/server.js +581 -209
  196. package/payload/server/{src-4F37OHLK.js → src-YLGAZY36.js} +5 -1
  197. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/passcode.ts +0 -73
  198. package/payload/server/public/assets/chat-Yf8Nkop8.js +0 -1
  199. package/payload/server/public/assets/chevron-left-CAiAUBxO.js +0 -1
  200. package/payload/server/public/assets/data-hqdYXaEn.js +0 -1
  201. package/payload/server/public/assets/page-B4c4_4HP.js +0 -32
  202. /package/payload/server/public/assets/{useSubAccountSwitcher-wo_qGW-I.js → useSubAccountSwitcher-CU-lOxTd.js} +0 -0
@@ -0,0 +1,193 @@
1
+ // Passcode generation, strength and hashing for the data portal.
2
+ //
3
+ // This module is JavaScript, not TypeScript, and that is deliberate: it has two
4
+ // consumers on two runtimes. The Worker imports it (wrangler bundles JS), and
5
+ // enrolment imports it from bare node on-device, which cannot load .ts. A second
6
+ // copy of the hash for the enrolment side would drift, and the failure mode of
7
+ // drift is that enrolment writes hashes the Worker rejects. JSDoc plus
8
+ // `typecheck:templates --checkJs` keeps the type cover 1689 added.
9
+ //
10
+ // constantTimeEqual is hand-rolled deliberately: crypto.subtle.timingSafeEqual
11
+ // is a Cloudflare extension with no Node equivalent, so depending on it would
12
+ // make the comparison untestable here.
13
+
14
+ /** Crockford base32: digits and letters minus I, L, O and U, which are the ones
15
+ * misread when the operator conveys a passcode to a person out of band. 32
16
+ * symbols is exactly 5 bits, and 256 divides by 32, so a masked random byte is
17
+ * an unbiased draw with no rejection sampling.
18
+ *
19
+ * How much of Crockford this portal takes, stated so nobody has to infer it
20
+ * from behaviour again (Task 1713):
21
+ *
22
+ * ADOPTED — the excluded letters, above, and case-insensitivity on decode.
23
+ * The alphabet is uppercase and so is everything stored;
24
+ * verifyPasscode uppercases what it is given, so a person may type
25
+ * either case. That is the one normalisation point.
26
+ * DECLINED — the letter-to-digit decode (I and L to 1, O to 0), check
27
+ * symbols, and hyphen tolerance. None are implemented.
28
+ *
29
+ * So "Crockford" here means this alphabet plus the case rule, and nothing
30
+ * further. 1708 shipped the first half without the second, which is the gap
31
+ * 1713 closed; a future reader adding the rest should treat it as a new
32
+ * decision, not as finishing this one.
33
+ */
34
+ export const PASSCODE_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
35
+
36
+ /** 26 x 5 bits = 130, which clears the 128-bit floor this portal's security
37
+ * rests on. Changing this constant changes that guarantee: see the note at
38
+ * hashPasscode before touching it. */
39
+ export const PASSCODE_LENGTH = 26
40
+
41
+ /** What PASSCODE_LENGTH and PASSCODE_ALPHABET buy, stated once so a test can
42
+ * pin it rather than a comment asserting it. */
43
+ export const PASSCODE_ENTROPY_BITS = PASSCODE_LENGTH * 5
44
+
45
+ /** Enrolment refused a passcode below the pinned strength. Named rather than a
46
+ * bare Error so a caller can tell a weak passcode from a broken salt. */
47
+ export class PasscodeTooWeakError extends Error {
48
+ /** @param {string} detail */
49
+ constructor(detail) {
50
+ super(`passcode too weak: ${detail}`)
51
+ this.name = 'PasscodeTooWeakError'
52
+ /** @type {'passcode-too-weak'} */
53
+ this.reason = 'passcode-too-weak'
54
+ }
55
+ }
56
+
57
+ /** Mint a passcode. This is the ONLY source of passcode entropy in the portal —
58
+ * assertPasscodeStrength below cannot supply it, only check the shape.
59
+ * @returns {string} */
60
+ export function generatePasscode() {
61
+ const bytes = new Uint8Array(PASSCODE_LENGTH)
62
+ crypto.getRandomValues(bytes)
63
+ let out = ''
64
+ // & 31 is unbiased here precisely because the alphabet is 32 long.
65
+ for (const b of bytes) out += PASSCODE_ALPHABET[b & 31]
66
+ return out
67
+ }
68
+
69
+ /** Refuse a passcode that is not the pinned length drawn from the pinned
70
+ * alphabet.
71
+ *
72
+ * This is a STRUCTURAL check and nothing more. It cannot measure entropy: a
73
+ * 26-character run of 'A' passes it. Do not describe it as enforcing
74
+ * PASSCODE_ENTROPY_BITS — an arbitrary string's entropy is not a computable
75
+ * property of the string. What it does buy is the case that motivated it: an
76
+ * enrolling agent satisfying "high-entropy passcode" with a six-digit number
77
+ * can no longer store one. Entropy itself comes from generatePasscode.
78
+ * @param {string} passcode
79
+ * @returns {void} */
80
+ export function assertPasscodeStrength(passcode) {
81
+ if (typeof passcode !== 'string' || passcode.length !== PASSCODE_LENGTH) {
82
+ throw new PasscodeTooWeakError(`expected ${PASSCODE_LENGTH} characters`)
83
+ }
84
+ for (const c of passcode) {
85
+ if (!PASSCODE_ALPHABET.includes(c)) {
86
+ throw new PasscodeTooWeakError('character outside the pinned alphabet')
87
+ }
88
+ }
89
+ }
90
+
91
+ /** @param {Uint8Array} bytes @returns {string} */
92
+ export function randomSaltHex(bytes) {
93
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
94
+ }
95
+
96
+ /** Returns Uint8Array<ArrayBuffer>, not the bare Uint8Array: the bare form
97
+ * widens to ArrayBufferLike, which Web Crypto will not accept as a BufferSource
98
+ * because a SharedArrayBuffer could back it.
99
+ * @param {string} hex
100
+ * @returns {Uint8Array<ArrayBuffer> | null} */
101
+ function hexToBytes(hex) {
102
+ if (hex.length === 0 || hex.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(hex)) return null
103
+ const out = new Uint8Array(hex.length / 2)
104
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
105
+ return out
106
+ }
107
+
108
+ /** Hash a passcode for storage: one SHA-256 over salt || passcode.
109
+ *
110
+ * ONE fast hash, no key stretching, and that is safe ONLY because passcodes are
111
+ * machine-generated by generatePasscode at PASSCODE_LENGTH. Stretching exists to
112
+ * make guessing a HUMAN-CHOSEN password expensive; 130 random bits are
113
+ * unguessable however fast the hash is, which is why nobody stretches a
114
+ * magic-link token either.
115
+ *
116
+ * So: if anyone ever lets an operator choose a memorable passcode, key
117
+ * stretching must come back in the SAME change. The danger here is not the fast
118
+ * hash. It is a later relaxation of generated-only while the fast hash stays.
119
+ *
120
+ * The salt earns its keep even without stretching: two people holding the same
121
+ * passcode still get different stored hashes, and no precomputed table over the
122
+ * alphabet is reusable across rows.
123
+ * @param {string} passcode
124
+ * @param {string} saltHex
125
+ * @returns {Promise<string>} */
126
+ export async function hashPasscode(passcode, saltHex) {
127
+ assertPasscodeStrength(passcode)
128
+ const salt = hexToBytes(saltHex)
129
+ if (!salt) throw new Error('salt must be hex')
130
+ const encoded = new TextEncoder().encode(passcode)
131
+ const message = new Uint8Array(salt.length + encoded.length)
132
+ message.set(salt, 0)
133
+ message.set(encoded, salt.length)
134
+ const digest = await crypto.subtle.digest('SHA-256', message)
135
+ return randomSaltHex(new Uint8Array(digest))
136
+ }
137
+
138
+ /** Verify a passcode against a stored salt and hash. Returns false rather than
139
+ * throwing on any malformed input, including a passcode that would not pass
140
+ * enrolment: sign-in is not the place to crash over a bad row.
141
+ * @param {string} passcode
142
+ * @param {string} saltHex
143
+ * @param {string} expectedHashHex
144
+ * @returns {Promise<boolean>} */
145
+ export async function verifyPasscode(passcode, saltHex, expectedHashHex) {
146
+ const expected = hexToBytes(expectedHashHex)
147
+ if (!expected) return false
148
+ /** @type {string} */
149
+ let actualHex
150
+ try {
151
+ // Crockford is case-insensitive on decode, and 1708 chose this alphabet for
152
+ // the very reason that rule exists: a person transcribes the passcode by
153
+ // hand. Uppercasing here is the second half of that choice (Task 1713), so
154
+ // the case someone types stops being something they can get wrong.
155
+ //
156
+ // This is the ONE normalisation point. hashPasscode and
157
+ // assertPasscodeStrength stay strict on the canonical uppercase form, so
158
+ // what enrolment stores is unchanged and only accepted INPUT widens.
159
+ //
160
+ // Inside the try deliberately: a malformed PASSCODE must return false rather
161
+ // than throw, and .toUpperCase() throws on a non-string. Outside the try,
162
+ // that would break for exactly the input class it exists to cover.
163
+ // (Scoped to the passcode on purpose. The docblock above says "any
164
+ // malformed input", which is not quite true of expectedHashHex — a null one
165
+ // throws out of hexToBytes before this try is entered. Unreachable today,
166
+ // since schema.sql pins hash TEXT NOT NULL; tracked in Task 1718 rather
167
+ // than restated here as though it held.)
168
+ //
169
+ // The strength gate therefore applies to the normalised form: verification
170
+ // accepts any string whose uppercase form is a valid passcode. One
171
+ // consequence, left unguarded on purpose — 'ß' uppercases to 'SS', so a
172
+ // 25-character string containing it can normalise onto a 26-character
173
+ // passcode. Building one requires already knowing the passcode, so it costs
174
+ // nothing an attacker could spend, and a fourth check to close it would buy
175
+ // no security.
176
+ actualHex = await hashPasscode(passcode.toUpperCase(), saltHex)
177
+ } catch {
178
+ return false
179
+ }
180
+ const actual = hexToBytes(actualHex)
181
+ if (!actual) return false
182
+ return constantTimeEqual(actual, expected)
183
+ }
184
+
185
+ /** @param {Uint8Array} a @param {Uint8Array} b @returns {boolean} */
186
+ export function constantTimeEqual(a, b) {
187
+ // Length is not secret; an early return on it leaks nothing a caller cannot
188
+ // already see. The byte loop below never short-circuits.
189
+ if (a.length !== b.length) return false
190
+ let diff = 0
191
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]
192
+ return diff === 0
193
+ }
@@ -1,5 +1,5 @@
1
1
  import type { Handler, Logger, PortalEnv } from './_lib/types'
2
- import { PBKDF2_ITERATIONS, verifyPasscode } from './_lib/passcode'
2
+ import { verifyPasscode } from './_lib/passcode.mjs'
3
3
  import { checkAndRecordAttempt, clearAttempts, rateScope } from './_lib/ratelimit'
4
4
  import { SESSION_COOKIE, SESSION_TTL_MS, mintSession, passcodeCheck } from './_lib/session'
5
5
  import { q } from './_lib/log'
@@ -17,11 +17,19 @@ interface AuthResult extends Handler {
17
17
  // them would enumerate who is enrolled.
18
18
  const DENIED: Handler = { status: 401, payload: { ok: false, error: 'denied' } }
19
19
 
20
- // A real-shaped salt and hash used only to burn the same PBKDF2 cost when the
21
- // owner does not exist. Without it, an unknown owner returns after one D1 read
22
- // (single-digit ms) while a known one spends ~250ms deriving — a timing oracle
23
- // three orders of magnitude wide, which enumerates the enrolled people outright
24
- // and makes the identical DENIED payload above cosmetic.
20
+ // A real-shaped salt and hash used only to burn the same hash when the owner
21
+ // does not exist, so the two paths cost the same and the identical DENIED
22
+ // payload above is not merely cosmetic.
23
+ //
24
+ // Task 1708 re-measured this after replacing PBKDF2 with one SHA-256, because
25
+ // the gap it closes shrank by four orders of magnitude and the dummy might have
26
+ // stopped earning its place. It did not. Without it the delta is one hash —
27
+ // measured at 43.8us (dev Mac, off-platform) — and published remote-timing work
28
+ // resolves roughly 15-20us on a LAN and ~100us over the internet, so 43.8us sits
29
+ // inside the demonstrated-resolvable band, and an attacker gets to pick a
30
+ // Cloudflare PoP near the target. Keeping it costs 43.8us against a 10ms CPU
31
+ // budget, which is 0.4% of it. Cheap enough not to think about; measurable
32
+ // enough not to remove.
25
33
  const DUMMY_SALT = '00000000000000000000000000000000'
26
34
  const DUMMY_HASH = '0'.repeat(64)
27
35
 
@@ -52,20 +60,18 @@ export async function processAuth(
52
60
  return { status: 429, payload: { ok: false, error: 'too many attempts' } }
53
61
  }
54
62
 
55
- const person = await env.DB.prepare(
56
- 'SELECT ownerId, salt, hash, iterations FROM people WHERE ownerId = ?',
57
- )
63
+ const person = await env.DB.prepare('SELECT ownerId, salt, hash FROM people WHERE ownerId = ?')
58
64
  .bind(ownerId)
59
- .first<{ ownerId: string; salt: string; hash: string; iterations: number }>()
65
+ .first<{ ownerId: string; salt: string; hash: string }>()
60
66
 
61
67
  if (!person) {
62
68
  // Burn the same work an enrolled owner would cost, then deny identically.
63
- await verifyPasscode(passcode, DUMMY_SALT, DUMMY_HASH, PBKDF2_ITERATIONS)
69
+ await verifyPasscode(passcode, DUMMY_SALT, DUMMY_HASH)
64
70
  log(`[data-portal] op=auth owner=${q(ownerId)} result=denied reason=no-such-owner`)
65
71
  return DENIED
66
72
  }
67
73
 
68
- const ok = await verifyPasscode(passcode, person.salt, person.hash, person.iterations)
74
+ const ok = await verifyPasscode(passcode, person.salt, person.hash)
69
75
  if (!ok) {
70
76
  log(`[data-portal] op=auth owner=${q(ownerId)} result=denied reason=bad-passcode`)
71
77
  return DENIED
@@ -4,16 +4,25 @@
4
4
  -- what they have dropped (manifest), and how hard they have been trying
5
5
  -- (auth_attempts). Everything is IF NOT EXISTS so re-applying is safe.
6
6
 
7
- -- One row per enrolled person. No plaintext passcode is ever stored: the skill
8
- -- generates the passcode, shows it to the operator once, and persists only the
9
- -- PBKDF2 salt and hash. `iterations` is stored per row so the cost can be
10
- -- raised later without invalidating existing rows.
7
+ -- One row per enrolled person. No plaintext passcode is ever stored: the
8
+ -- enrolment script generates the passcode, prints it to the operator once, and
9
+ -- persists only the salt and a SHA-256 of salt || passcode.
10
+ --
11
+ -- There is no `iterations` column. Verification is a single fast hash, which is
12
+ -- safe only because passcodes are machine-generated at a pinned length — see the
13
+ -- note at hashPasscode in functions/api/_lib/passcode.mjs. A cost column here
14
+ -- would imply a tunable that does not exist.
15
+ --
16
+ -- `iterations` was dropped outright rather than migrated because no portal had
17
+ -- been deployed when it went (Task 1708). If you meet a `people` table that still
18
+ -- has the column, this file will NOT remove it — IF NOT EXISTS is a no-op on an
19
+ -- existing table — and enrolment's INSERT will fail on `iterations NOT NULL`.
20
+ -- Drop the column by hand in that case; do not add it back here.
11
21
  CREATE TABLE IF NOT EXISTS people (
12
22
  ownerId TEXT PRIMARY KEY,
13
23
  name TEXT NOT NULL,
14
24
  salt TEXT NOT NULL,
15
25
  hash TEXT NOT NULL,
16
- iterations INTEGER NOT NULL,
17
26
  createdAt TEXT NOT NULL
18
27
  );
19
28
 
@@ -33,6 +33,8 @@ There is never a `sites/<brand>/` versus `output/<brand>-site/` pair, a `.bak` c
33
33
 
34
34
  ## Auth: reuse the per-scope token, never mint ad hoc
35
35
 
36
+ > **Client accounts on a multi-tenant install do not use this section.** Post-1631 their secrets carry no master and `cf-token.sh` denies them the `pages` scope (`reason=not-house`). They publish with `storage-pages-deploy`, which deploys house-side against a project the house has recorded to them (`storage-pages-adopt`). A `not-house` denial here is that boundary working, not a token fault — do not try to mint around it. Everything below is the **house/single-tenant** path.
37
+
36
38
  Resolve the Pages-and-D1 token with the deterministic helper **before** any token `curl`, never a hand-built one:
37
39
 
38
40
  ```bash
@@ -13,14 +13,15 @@
13
13
  *
14
14
  * Claude Code CLI → shim (this file) = node running <real-entry> in-process
15
15
  * stdin/stdout : untouched (JSON-RPC channel)
16
- * stderr : process.stderr.write teed → per-date + per-session + passthrough
16
+ * stderr : process.stderr.write teed → raw sink + passthrough
17
17
  *
18
- * Destinations (Task 706) — unchanged:
19
- * - `${LOG_DIR}/mcp-<name>-stderr-<date>.log` per-date raw (back-compat;
20
- * the in-process mcp-stderr-tee and the [mcp-init-error] probe read it).
21
- * - `${LOG_DIR}/mcp-<name>-<SESSION_ID>.log` — per-session raw + lifecycle
22
- * lines. Authoritative sink. Absent when SESSION_ID is unset (enumeration
23
- * spawn)then the per-date file is the fallback.
18
+ * Destinations:
19
+ * - `${LOG_DIR}/mcp-<name>-<SESSION_ID>.log` — raw stderr + lifecycle lines.
20
+ * The sole raw sink. Task 1721 retired the per-date twin: its date was
21
+ * pinned at spawn, no reader ever parsed it, and mcp-stderr-tee wrote the
22
+ * same file, so every line landed twice.
23
+ * - `${LOG_DIR}/mcp-<name>-nosession.log` — the same, for a spawn with
24
+ * no SESSION_ID (enumeration). One fixed file per server.
24
25
  * - `server.log` via the loopback log-ingest route — best-effort mirror of
25
26
  * the [mcp-helper] lifecycle lines.
26
27
  *
@@ -49,13 +50,13 @@ const PLATFORM_PORT = process.env.PLATFORM_PORT;
49
50
  const ENTRY = process.argv[2];
50
51
  const SESSION_ID8 = SESSION_ID ? SESSION_ID.slice(0, 8) : "—";
51
52
  const spawnStamp = Date.now();
52
- // Per-session raw + lifecycle log (Task 706). Empty SESSION_ID (enumeration
53
- // spawn) undefined, and the per-date file is the fallback.
54
- const perSessionPath = LOG_DIR && SESSION_ID
55
- ? (0, node_path_1.resolve)(LOG_DIR, `mcp-${SERVER_NAME}-${SESSION_ID}.log`)
56
- : undefined;
57
- const perDatePath = LOG_DIR
58
- ? (0, node_path_1.resolve)(LOG_DIR, `mcp-${SERVER_NAME}-stderr-${new Date(spawnStamp).toISOString().slice(0, 10)}.log`)
53
+ // The one raw sink (Task 1721). A sessionless spawn (rc-daemon stamps
54
+ // SESSION_ID='' rc-daemon.ts:513 while still stamping LOG_DIR) shares one
55
+ // fixed file per server rather than a date-named one: the date was never read
56
+ // by any reader and named a process lifetime, not a day. Interleaved spawns in
57
+ // the nosession file are separated by the [mcp-helper] op=spawn pid= lines.
58
+ const rawSinkPath = LOG_DIR
59
+ ? (0, node_path_1.resolve)(LOG_DIR, SESSION_ID ? `mcp-${SERVER_NAME}-${SESSION_ID}.log` : `mcp-${SERVER_NAME}-nosession.log`)
59
60
  : undefined;
60
61
  // Rolling tail of entry stderr for op=exit. Capped so a chatty server cannot
61
62
  // grow the buffer without bound.
@@ -66,7 +67,7 @@ let exitEmitted = false;
66
67
  let exitSignal;
67
68
  // The real stderr writer, captured before the tee replaces it. Lifecycle lines
68
69
  // and stderr passthrough both go through this so they are never re-teed into
69
- // the per-date sink nor recursed back into the patched writer.
70
+ // the raw sink nor recursed back into the patched writer.
70
71
  const rawStderrWrite = process.stderr.write.bind(process.stderr);
71
72
  // LOG_DIR is created once, lazily, on the first successful append — not per
72
73
  // chunk. teeWrite runs on the server's own stderr hot path now, so a per-call
@@ -115,7 +116,7 @@ function postToServerLog(suffix, level) {
115
116
  // log-ingest route rejects newlines).
116
117
  function emitLifecycle(suffix, level) {
117
118
  const line = `[mcp-helper] ${suffix}\n`;
118
- appendSafe(perSessionPath, line);
119
+ appendSafe(rawSinkPath, line);
119
120
  try {
120
121
  rawStderrWrite(line);
121
122
  }
@@ -127,12 +128,11 @@ if (!ENTRY) {
127
128
  process.exit(2);
128
129
  }
129
130
  // Replace process.stderr.write with a tee: mirror every server stderr byte to
130
- // the per-date and per-session sinks, keep a rolling tail for op=exit, emit
131
+ // the raw sink, keep a rolling tail for op=exit, emit
131
132
  // op=boot on the first bytes, then pass the write through to the real stderr.
132
133
  const teeWrite = ((...args) => {
133
134
  const chunk = args[0];
134
- appendSafe(perDatePath, chunk); // per-date raw (back-compat)
135
- appendSafe(perSessionPath, chunk); // per-session raw (Task 706)
135
+ appendSafe(rawSinkPath, chunk); // raw sink (Task 1721)
136
136
  const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
137
137
  stderrTail = (stderrTail + text).slice(-TAIL_CAP);
138
138
  if (!bootEmitted) {
@@ -13,14 +13,15 @@
13
13
  *
14
14
  * Claude Code CLI → shim (this file) = node running <real-entry> in-process
15
15
  * stdin/stdout : untouched (JSON-RPC channel)
16
- * stderr : process.stderr.write teed → per-date + per-session + passthrough
16
+ * stderr : process.stderr.write teed → raw sink + passthrough
17
17
  *
18
- * Destinations (Task 706) — unchanged:
19
- * - `${LOG_DIR}/mcp-<name>-stderr-<date>.log` per-date raw (back-compat;
20
- * the in-process mcp-stderr-tee and the [mcp-init-error] probe read it).
21
- * - `${LOG_DIR}/mcp-<name>-<SESSION_ID>.log` — per-session raw + lifecycle
22
- * lines. Authoritative sink. Absent when SESSION_ID is unset (enumeration
23
- * spawn)then the per-date file is the fallback.
18
+ * Destinations:
19
+ * - `${LOG_DIR}/mcp-<name>-<SESSION_ID>.log` — raw stderr + lifecycle lines.
20
+ * The sole raw sink. Task 1721 retired the per-date twin: its date was
21
+ * pinned at spawn, no reader ever parsed it, and mcp-stderr-tee wrote the
22
+ * same file, so every line landed twice.
23
+ * - `${LOG_DIR}/mcp-<name>-nosession.log` — the same, for a spawn with
24
+ * no SESSION_ID (enumeration). One fixed file per server.
24
25
  * - `server.log` via the loopback log-ingest route — best-effort mirror of
25
26
  * the [mcp-helper] lifecycle lines.
26
27
  *
@@ -49,13 +50,13 @@ const PLATFORM_PORT = process.env.PLATFORM_PORT;
49
50
  const ENTRY = process.argv[2];
50
51
  const SESSION_ID8 = SESSION_ID ? SESSION_ID.slice(0, 8) : "—";
51
52
  const spawnStamp = Date.now();
52
- // Per-session raw + lifecycle log (Task 706). Empty SESSION_ID (enumeration
53
- // spawn) undefined, and the per-date file is the fallback.
54
- const perSessionPath = LOG_DIR && SESSION_ID
55
- ? (0, node_path_1.resolve)(LOG_DIR, `mcp-${SERVER_NAME}-${SESSION_ID}.log`)
56
- : undefined;
57
- const perDatePath = LOG_DIR
58
- ? (0, node_path_1.resolve)(LOG_DIR, `mcp-${SERVER_NAME}-stderr-${new Date(spawnStamp).toISOString().slice(0, 10)}.log`)
53
+ // The one raw sink (Task 1721). A sessionless spawn (rc-daemon stamps
54
+ // SESSION_ID='' rc-daemon.ts:513 while still stamping LOG_DIR) shares one
55
+ // fixed file per server rather than a date-named one: the date was never read
56
+ // by any reader and named a process lifetime, not a day. Interleaved spawns in
57
+ // the nosession file are separated by the [mcp-helper] op=spawn pid= lines.
58
+ const rawSinkPath = LOG_DIR
59
+ ? (0, node_path_1.resolve)(LOG_DIR, SESSION_ID ? `mcp-${SERVER_NAME}-${SESSION_ID}.log` : `mcp-${SERVER_NAME}-nosession.log`)
59
60
  : undefined;
60
61
  // Rolling tail of entry stderr for op=exit. Capped so a chatty server cannot
61
62
  // grow the buffer without bound.
@@ -66,7 +67,7 @@ let exitEmitted = false;
66
67
  let exitSignal;
67
68
  // The real stderr writer, captured before the tee replaces it. Lifecycle lines
68
69
  // and stderr passthrough both go through this so they are never re-teed into
69
- // the per-date sink nor recursed back into the patched writer.
70
+ // the raw sink nor recursed back into the patched writer.
70
71
  const rawStderrWrite = process.stderr.write.bind(process.stderr);
71
72
  // LOG_DIR is created once, lazily, on the first successful append — not per
72
73
  // chunk. teeWrite runs on the server's own stderr hot path now, so a per-call
@@ -115,7 +116,7 @@ function postToServerLog(suffix, level) {
115
116
  // log-ingest route rejects newlines).
116
117
  function emitLifecycle(suffix, level) {
117
118
  const line = `[mcp-helper] ${suffix}\n`;
118
- appendSafe(perSessionPath, line);
119
+ appendSafe(rawSinkPath, line);
119
120
  try {
120
121
  rawStderrWrite(line);
121
122
  }
@@ -127,12 +128,11 @@ if (!ENTRY) {
127
128
  process.exit(2);
128
129
  }
129
130
  // Replace process.stderr.write with a tee: mirror every server stderr byte to
130
- // the per-date and per-session sinks, keep a rolling tail for op=exit, emit
131
+ // the raw sink, keep a rolling tail for op=exit, emit
131
132
  // op=boot on the first bytes, then pass the write through to the real stderr.
132
133
  const teeWrite = ((...args) => {
133
134
  const chunk = args[0];
134
- appendSafe(perDatePath, chunk); // per-date raw (back-compat)
135
- appendSafe(perSessionPath, chunk); // per-session raw (Task 706)
135
+ appendSafe(rawSinkPath, chunk); // raw sink (Task 1721)
136
136
  const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
137
137
  stderrTail = (stderrTail + text).slice(-TAIL_CAP);
138
138
  if (!bootEmitted) {
@@ -112,7 +112,7 @@ Each session row carries a usage icon (Lucide `Gauge`) that opens a modal with a
112
112
 
113
113
  ## Memory MCP write-path outcome lines
114
114
 
115
- Every write-path tool in the memory plugin MCP emits one structured line per invocation to `server.log` via the loopback `/api/admin/log-ingest` route. The operator can answer "did this write succeed?" from a single greppable surface — no need to cross-reference the per-process `mcp-memory-stderr-<date>.log`.
115
+ Every write-path tool in the memory plugin MCP emits one structured line per invocation to `server.log` via the loopback `/api/admin/log-ingest` route. The operator can answer "did this write succeed?" from a single greppable surface — no need to cross-reference the per-session `mcp-memory-<sessionId>.log`.
116
116
 
117
117
  Line shapes:
118
118
 
@@ -137,10 +137,10 @@ grep '\[mcp:memory\] .* result=error' ~/.${brand}/logs/server.log | awk -F'reaso
137
137
  # Did the loopback POST itself fail? (Helper falls back to stderr and re-writes
138
138
  # the structured line, so the event is never lost — this just flags that the
139
139
  # session-manager's /api/admin/log-ingest was unreachable for some window.)
140
- grep '\[mcp:memory\] log-ingest-failed' ~/.${brand}/logs/mcp-memory-stderr-*.log
140
+ grep '\[mcp:memory\] log-ingest-failed' ~/.${brand}/logs/mcp-memory-*.log
141
141
  ```
142
142
 
143
- The existing `mcp-memory-stderr-<date>.log` capture is unchanged on error paths — the new structured line is additive, not a relocation. Crash-mode debugging continues to read the stderr log; routine "did it land?" questions read `server.log`.
143
+ The existing raw capture is unchanged on error paths — the new structured line is additive, not a relocation. Crash-mode debugging continues to read the raw sink `mcp-memory-<sessionId>.log`; routine "did it land?" questions read `server.log`.
144
144
 
145
145
  **Test contract.** Each of the 20 wired tools carries a `*-emit.test.ts` fixture under `platform/plugins/memory/mcp/src/tools/__tests__/` that mocks `fetch` and asserts the wire body. A refactor that drops `withWriteEvent` from any tool fails the matching tool's tests with a clear `expected "vi.fn()" to be called 1 times, but got 0 times` error, naming the tool. A shared helper at `__tests__/_helpers/emit-capture.ts` standardises the fetch capture + Neo4j Session stub so each tool test stays a few lines of mocks + one happy + one error assertion. Drift between the helper's body shape and the route's validator is pinned by an end-to-end test at `platform/ui/server/routes/admin/__tests__/log-ingest-mcp-end-to-end.test.ts`, which routes the helper's POST through the real Hono `log-ingest` route with a loopback `remoteAddress` shim.
146
146
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  Each installation has its own Cloudflare account. The **tunnel** sign-in is OAuth: the agent invokes `cloudflared tunnel login` via Bash; the Cloudflare Authorize URL streams into the admin chat PTY and the native terminal renders it as a clickable link. Click it, authorise in your own browser, and `cloudflared` writes `cert.pem` to the brand's config directory. For **everything else** (DNS, Pages, D1, Access) the agent uses the Cloudflare API, authenticated by a short-lived narrow token it mints from a master token you provision once in the dashboard (an advanced step the agent never automates). Some account-side jobs — adding a domain, switching accounts — are still easiest in your browser, and the agent relays those click-paths; the rest it can do directly via the API.
4
4
 
5
+ Where one installation serves several client accounts from one Cloudflare account, that master is held by the house account alone, and a client account cannot mint from it — it is account-wide, so handing it to one client would hand them every other client's data. Publishing a client's website therefore runs house-side on the client's behalf: the client's session asks, the house does the deploy, and no Cloudflare token ever enters the client's account. Which website belongs to which client is recorded once by the house and is never inferred from folder names, so a site nobody has recorded cannot be published by anyone until it is.
6
+
5
7
  ## Identity model
6
8
 
7
9
  | Concept | Source |
@@ -34,7 +34,7 @@ The `outlook` plugin gives the admin agent access to Microsoft 365 / Outlook.com
34
34
  | `outlook-mailbox-info` | Health probe for one mailbox — auth state, refresh-window, folder count. Name the mailbox with `mailbox` when several are attached. |
35
35
  | `outlook-mailbox-list` | Every connected mailbox with its email, scopes, and token health. Local only — no network — so it also flags a mailbox whose stored credentials cannot be read. |
36
36
  | `outlook-mail-send` | Send a message. No attachments → `POST /me/sendMail`; with attachments → draft, attach, send (`/me/sendMail` carries attachments inline and cannot fit a large file). to/cc/bcc arrays, subject, body, isHtml, attachments (account-dir paths, 25 MB/file). Success asserted on Graph 202; no message id on either route. Needs `Mail.Send` (+ `Mail.ReadWrite` to attach). |
37
- | `outlook-draft` | Create a draft in Drafts (`POST /me/messages`). Same body model, including attachments files are attached before the id is returned, so the draft is ready to send. Returns the draft id. Needs `Mail.ReadWrite`. |
37
+ | `outlook-draft` | Create a draft in Drafts, either a new message or a threaded reply. Returns the draft id; never sends. Attachments are attached before the id is returned, so the draft is ready to send. Needs `Mail.ReadWrite`. **New message** (no `replyToMessageId`): `POST /me/messages`, requires `to` + `subject`, same body model as `outlook-mail-send`, threads under nothing. **Threaded reply** (`replyToMessageId` = the id of the message being answered): `POST /me/messages/{id}/createReply` — the draft carries the parent's conversationId, so it lands under the original. This is the only way to draft a reply without sending it: `outlook-mail-reply` threads but always sends. Graph sets the recipients and `RE:` subject from the original and composes the body above its own quote, matching what `outlook-mail-reply` sends, so the threaded path is **plain text only** — `to`, `subject`, and `isHtml: true` are **refused** (not ignored) when replying. `replyAll` includes everyone on the original; `cc`/`bcc` add recipients. |
38
38
  | `outlook-draft-edit` | Update an existing draft in place (`PATCH /me/messages/{draftId}`) and/or add and remove attachments. Graph drafts are mutable — pass draftId plus the fields to change (to/cc/bcc/subject/body/isHtml/attachments/removeAttachments). `attachments` adds; `removeAttachments` takes files off by name or attachment id; both together replace a file in one call. Sent messages cannot be edited. Needs `Mail.ReadWrite`. |
39
39
  | `outlook-draft-send` | Send an existing draft by id (`POST /me/messages/{id}/send`). Graph consumes the draft, returns 202. Needs `Mail.Send`. |
40
40
 
@@ -76,9 +76,9 @@ All log lines start with `[outlook-mcp]` and write to `server.log`. They are key
76
76
  | Send args | `send-args account=<id> recipients=<N> hasBody=<bool>` |
77
77
  | Sent | `sent account=<id> status=202 messageId=<id-or-null>` |
78
78
  | Send failed | `send-failed account=<id> error=<msg>` |
79
- | Draft request | `draft-request account=<id>` |
80
- | Draft created | `draft-created account=<id> id=<draftId>` |
81
- | Draft failed | `draft-failed account=<id> error=<msg>` |
79
+ | Draft request | `draft-request account=<id> threaded=<bool>` |
80
+ | Draft created | `draft-created account=<id> id=<draftId> threaded=<bool> convId=<id\|null>` |
81
+ | Draft failed | `draft-failed account=<id> threaded=<bool> error=<msg>` |
82
82
  | Draft-send request | `draft-send-request account=<id> id=<draftId>` |
83
83
  | Draft sent | `draft-sent account=<id> id=<draftId> status=202` |
84
84
  | Draft-send failed | `draft-send-failed account=<id> id=<draftId> error=<msg>` |
@@ -91,8 +91,15 @@ ssh laptop 'grep -E "^\[outlook-mcp\]" ~/.maxy/logs/server.log | grep "account=<
91
91
 
92
92
  # Token-leak audit — must always return zero
93
93
  grep -rn -iE "Bearer |access_token=" ~/.maxy/logs/server.log | head
94
+
95
+ # Threading audit — a threaded write that reports no conversation LOST its thread.
96
+ # Must always return zero. Covers both outbound threaded paths (mail-reply and draft-created).
97
+ grep -E "^\[outlook-mcp\] (mail-reply|draft-created)" ~/.maxy/logs/server.log \
98
+ | grep -E "threaded=true|op=reply" | grep -E "convId=(null)?( |$)" | head
94
99
  ```
95
100
 
101
+ Threading triage: **absence of a `convId` on a threaded write means threading was lost.** `draft-created threaded=false convId=null` is correct and expected — a new message has no parent conversation. `draft-created threaded=true convId=null` is a fault: Graph returned a reply draft that names no conversation, and the reply will land detached from the original.
102
+
96
103
  Latency triage: `mail-list count=0 elapsedMs<200` consistent → permissions issue; `elapsedMs > 5000` → Graph slowness or DNS.
97
104
 
98
105
  ## Failure modes
@@ -161,7 +161,9 @@ import { initStderrTee } from "../../../../lib/mcp-stderr-tee/dist/index.js";
161
161
  initStderrTee("your-plugin-name");
162
162
  ```
163
163
 
164
- After this, every `console.error("[your-tool]...")` from any tool in the plugin appears as `[<iso-ts>] [mcp:your-plugin-name] [your-tool]...` in the per-conversation stream log `claude-agent-stream-{sessionId}.log`, alongside the usual agent events. The raw per-server file `mcp-your-plugin-name-stderr-{date}.log` is still produced for deep-dive grep.
164
+ After this, every `console.error("[your-tool]...")` from any tool in the plugin is captured in the raw per-server file `mcp-your-plugin-name-{sessionId}.log`, written by the `mcp-spawn-tee` wrapper. That is where you grep.
165
+
166
+ `initStderrTee` is *intended* to also mirror each line into the per-conversation stream log `claude-agent-stream-{sessionId}.log` as `[<iso-ts>] [mcp:your-plugin-name] [your-tool]...`, alongside the usual agent events. It does not currently do so: that destination is gated on `STREAM_LOG_PATH`, which no spawner sets, so the call is a no-op in production. Do not expect `[mcp:<name>]` lines in a conversation's stream log, and do not read their absence as the server having emitted nothing. Tracked as [`.tasks/1736-*.md`](../../../.tasks/1736-nothing-sets-stream-log-path-so-the-per-conversation-mcp-stream-log-does-not-exist.md).
165
167
 
166
168
  **Premium plugins.** Source lives at `premium-plugins/<bundle>/plugins/<name>/mcp/src/` — deeper than platform plugins, so the source-relative import to `platform/lib/mcp-stderr-tee/dist/index.js` uses more `../` segments. The bundler rewrites the compiled output to the canonical `../../../../lib/mcp-stderr-tee/dist/index.js` at staging time and ships `platform/lib/mcp-stderr-tee/{dist,package.json}` into `premium-plugins/<bundle>/lib/mcp-stderr-tee/` so the import resolves at deployed depth. The bundler fails loudly if `platform/lib/mcp-stderr-tee/package.json` is missing (it must pin `type` so install-location parent walks cannot mis-classify the dist file) or if any lib referenced by a rewritten import has no source dist.
167
169
 
@@ -172,7 +174,7 @@ After this, every `console.error("[your-tool]...")` from any tool in the plugin
172
174
  **Retrieve MCP diagnostic lines for a conversation:**
173
175
 
174
176
  - All servers: `logs-read { type: "system", sessionId: "..." }` → grep `[mcp:<name>]` on the returned stream log.
175
- - One server raw feed: `logs-read { type: "mcp" }` → tails the most recent `mcp-<name>-stderr-*.log` (per-plugin, not per-conversation).
177
+ - One server raw feed: `logs-read { type: "mcp" }` → tails the most recent `mcp-<name>-*.log` (per-plugin, not per-conversation).
176
178
 
177
179
  **Tee-state markers** land in the stream log: `[platform] [mcp-tee-attach] server=<name> streamLogPath=...` when the tee wires up, `[platform] [mcp-tee-skip] server=<name> destination=... reason=...` when a destination fails (missing `LOG_DIR`, unwritable path, `STREAM_LOG_PATH` not set, etc.), `[platform] [mcp-tee-detach] server=<name>` on graceful shutdown. If a server invoked tools but no `[mcp:<name>]` lines appear in the conversation's log, look for the skip marker first.
178
180
 
@@ -180,16 +182,16 @@ After this, every `console.error("[your-tool]...")` from any tool in the plugin
180
182
 
181
183
  ## Failure-path observability contract (earlier platform fixes + earlier platform fixes)
182
184
 
183
- The `initStderrTee` wrapper writes to the per-conversation stream log and per-server raw file via `createWriteStream` — async, buffered. Any diagnostic `console.error(…)` followed by an immediate `process.exit(…)` is lost: the event loop never drains the WriteStream before the process terminates. Same race for any synchronous module-load throw: Node's uncaught-exception handler writes the stack to raw fd 2 and exits before the patched async stream flushes. The platform's `[mcp-init-error] tail="(no stderr file)"` line operationally useless is the public symptom of this race.
185
+ The `initStderrTee` wrapper writes to the per-conversation stream log via `createWriteStream` — async, buffered. Any diagnostic `console.error(…)` followed by an immediate `process.exit(…)` is lost from *that* destination: the event loop never drains the WriteStream before the process terminates. Same race for any synchronous module-load throw: Node's uncaught-exception handler writes the stack to raw fd 2 and exits before the patched async stream flushes. The raw sink does not have this race — removed `initStderrTee`'s own raw file, and `mcp-spawn-tee` writes the sink with `appendFileSync`, which returns once the kernel has queued the write and so survives an immediate exit.
184
186
 
185
187
  **Two layers now close the gap, each load-bearing on its own:**
186
188
 
187
189
  1. **Plugin-side sync-write discipline.** Plugins that call `process.exit` during module load (rare — `graph-mcp` is the in-tree example; it spawns a child at boot to proxy upstream stdio) use `fs.appendFileSync` at every named exit path to guarantee the cause lands in both log destinations before exit. Lines follow the `[mcp:<name>] [<plugin-prefix>] <cause>` format so existing `grep '[mcp:<name>]'` investigator paths work. Each destination is wrapped in its own try/catch — an unwritable log must not mask the primary failure. This is the discipline propagated to any plugin author who knows their failure paths.
188
190
 
189
- 2. **Parent-side `mcp-spawn-tee` wrapper.** Every node-based core MCP server is spawned via the `lib/mcp-spawn-tee` wrapper rather than `node <entry>` directly. The wrapper spawns the real entry with `stdio: ['inherit', 'inherit', 'pipe']` and writes child stderr chunks to `${LOG_DIR}/mcp-${name}-stderr-<date>.log` via `appendFileSync` while passing the same chunks through to its own stderr (Claude Code's consumer is unchanged). Synchronous `appendFileSync` survives `process.exit`, so the per-server file captures even (a) module-load throws before `initStderrTee` runs, (b) `MODULE_NOT_FOUND` on the entry script itself, and (c) anything else a plugin author missed. The wrapper writes `[mcp-spawn-tee-attached] server=<name> pid=<n>` on attach and forwards SIGTERM/SIGINT to the child. This is the layer that makes capture independent of plugin discipline. Playwright stays unwrapped because it spawns via `npx`, not `node`.
191
+ 2. **Parent-side `mcp-spawn-tee` wrapper.** Every node-based core MCP server is spawned via the `lib/mcp-spawn-tee` wrapper rather than `node <entry>` directly. The wrapper runs the real entry in its own process and writes stderr chunks to `${LOG_DIR}/mcp-${name}-${SESSION_ID}.log` (or `mcp-${name}-nosession.log` when `SESSION_ID` is unset) via `appendFileSync` while passing the same chunks through to its own stderr (Claude Code's consumer is unchanged). It is the only writer of that raw sink: retired the `mcp-${name}-stderr-<date>.log` twin, which `mcp-stderr-tee` also wrote, so every line used to land there twice. Synchronous `appendFileSync` survives `process.exit`, so the sink captures even (a) module-load throws before `initStderrTee` runs, (b) `MODULE_NOT_FOUND` on the entry script itself, and (c) anything else a plugin author missed. The wrapper writes `[mcp-spawn-tee-attached] server=<name> pid=<n>` on attach and forwards SIGTERM/SIGINT to the child. This is the layer that makes capture independent of plugin discipline. Playwright stays unwrapped because it spawns via `npx`, not `node`.
190
192
 
191
- A third layer closes the same gap from the platform side: when `claude-agent.ts` observes an `init` event with any MCP server reporting `status:"failed"`, it reads the last 512 bytes of `${LOG_DIR}/mcp-<name>-stderr-<date>.log` and emits `[mcp-init-error] server=<name> tail=<quoted>` into the stream log. Absent file `tail="(no stderr file)"`; empty file `tail="(empty)"`. With the spawn-tee wrapper now interposing on every core MCP, `tail="(no stderr file)"` means the wrapper itself is broken file follow-up.
193
+ A `[mcp-init-error]` probe on the platform side used to read the retired per-date file and tail it into the stream log. found no such probe in live code `claude-agent.ts` has not carried one for some time so the wrapper's own `[mcp-helper] op=boot` and `op=exit` lines are the record of a failed MCP boot.
192
194
 
193
- **Signal inventory after a failed session:** `[init] FAILED MCP servers: <names>` (names), `[mcp-init-error] server=<name> tail=…` (cause for each, from the platform's tail probe), `[mcp-spawn-tee-attached] server=<name> pid=<n>` (proof the wrapper attached), `[mcp-spawn-tee-exit] server=<name> code=<n>|signal=<s>` (proof the wrapper saw the exit), and optionally `[mcp:<name>] [<plugin>] …` from plugin-side sync-writes. Their union gives the investigator three independent sources for the same failure.
195
+ **Signal inventory after a failed session:** `[init] FAILED MCP servers: <names>` (names), `[mcp-spawn-tee-attached] server=<name> pid=<n>` (proof the wrapper attached), `[mcp-spawn-tee-exit] server=<name> code=<n>|signal=<s>` (proof the wrapper saw the exit), and optionally `[mcp:<name>] [<plugin>] …` from plugin-side sync-writes. Their union gives the investigator independent sources for the same failure. (The two `[mcp-spawn-tee-*]` names are stale — the shim emits `[mcp-helper] op=spawn|boot|exit`; see [`.tasks/1737-*.md`](../../../.tasks/1737-mcp-spawn-tee-docs-promise-lifecycle-markers-and-a-child-process-that-task-989-removed.md).)
194
196
 
195
197
  **Boot-smoke as publish-time gate.** The memory MCP carries `scripts/boot-smoke.sh` that spawns `dist/index.js` with stub env, sleeps 2s, asserts `kill -0 <pid>`, and reports `[boot-smoke] memory ok|FAILED tail=<n-lines>`. Wired to `prepublish` in `plugins/memory/mcp/package.json`. The pattern is propagatable to other plugin MCPs — it's deliberately not generalised yet because each plugin's stub-env requirements differ (memory needs ACCOUNT_ID + PLATFORM_ROOT + NEO4J_URI + SESSION_ID; others differ).