@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
@@ -1,74 +1,204 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import {
3
- PBKDF2_ITERATIONS,
3
+ PASSCODE_ALPHABET,
4
+ PASSCODE_LENGTH,
5
+ PASSCODE_ENTROPY_BITS,
6
+ PasscodeTooWeakError,
7
+ assertPasscodeStrength,
4
8
  constantTimeEqual,
9
+ generatePasscode,
5
10
  hashPasscode,
6
11
  verifyPasscode,
7
12
  randomSaltHex,
8
- } from '../../skills/data-portal/template/functions/api/_lib/passcode'
13
+ } from '../../skills/data-portal/template/functions/api/_lib/passcode.mjs'
9
14
 
10
15
  const SALT = '000102030405060708090a0b0c0d0e0f'
11
16
 
12
- describe('constantTimeEqual', () => {
13
- it('is true for identical arrays', () => {
14
- expect(constantTimeEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 3]))).toBe(true)
17
+ describe('the pinned strength', () => {
18
+ it('is at least 128 bits', () => {
19
+ expect(PASSCODE_ENTROPY_BITS).toBeGreaterThanOrEqual(128)
15
20
  })
16
- it('is false when a byte differs', () => {
17
- expect(constantTimeEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 4]))).toBe(false)
21
+ it('states an entropy figure the alphabet and length actually produce', () => {
22
+ // 32 symbols is 5 bits each, exactly, because 256 divides by 32.
23
+ expect(PASSCODE_ALPHABET.length).toBe(32)
24
+ expect(PASSCODE_ENTROPY_BITS).toBe(PASSCODE_LENGTH * 5)
18
25
  })
19
- it('is false for different lengths, without throwing', () => {
20
- expect(constantTimeEqual(new Uint8Array([1, 2]), new Uint8Array([1, 2, 3]))).toBe(false)
26
+ it('draws from an alphabet with no repeats and no ambiguous characters', () => {
27
+ expect(new Set(PASSCODE_ALPHABET).size).toBe(PASSCODE_ALPHABET.length)
28
+ for (const c of 'ILOU') expect(PASSCODE_ALPHABET).not.toContain(c)
21
29
  })
22
- it('compares every byte even after a mismatch (no early return)', () => {
23
- // A first-byte mismatch and a last-byte mismatch must both be false.
24
- // This does not measure timing; it pins that the function does not
25
- // short-circuit in a way a rewrite could reintroduce.
26
- expect(constantTimeEqual(new Uint8Array([9, 2, 3]), new Uint8Array([1, 2, 3]))).toBe(false)
27
- expect(constantTimeEqual(new Uint8Array([1, 2, 9]), new Uint8Array([1, 2, 3]))).toBe(false)
30
+ it('survives the case normalisation verifyPasscode applies, without folding', () => {
31
+ // Task 1713 made verification case-insensitive. This pins WHY that costs no
32
+ // entropy, rather than leaving a comment to assert it.
33
+ //
34
+ // The argument: generation emits exactly one case, so uppercasing is the
35
+ // identity over the generated set, and the map from a generated passcode to
36
+ // its normalised form is injective. Two distinct generated passcodes can
37
+ // therefore never fold onto one string, and PASSCODE_ENTROPY_BITS still
38
+ // counts what it says it counts. Normalisation widens accepted INPUT, not
39
+ // the search space.
40
+ //
41
+ // If this ever goes red, the alphabet has grown a character whose case
42
+ // folds, and the entropy figure above is overstated.
43
+ for (const c of PASSCODE_ALPHABET) expect(c.toUpperCase()).toBe(c)
44
+ expect(new Set([...PASSCODE_ALPHABET].map((c) => c.toUpperCase())).size).toBe(
45
+ PASSCODE_ALPHABET.length,
46
+ )
47
+ const generated = Array.from({ length: 200 }, () => generatePasscode())
48
+ for (const pc of generated) expect(pc.toUpperCase()).toBe(pc)
49
+ // Compared against the count of DISTINCT originals, not against 200: this
50
+ // asserts injectivity alone, and does not quietly also assert that 200 draws
51
+ // never repeat (which is generatePasscode's claim, tested separately).
52
+ expect(new Set(generated.map((pc) => pc.toUpperCase())).size).toBe(new Set(generated).size)
53
+ expect(PASSCODE_ENTROPY_BITS).toBe(PASSCODE_LENGTH * 5)
54
+ })
55
+ })
56
+
57
+ describe('generatePasscode', () => {
58
+ it('mints a passcode at the pinned length, from the pinned alphabet', () => {
59
+ const pc = generatePasscode()
60
+ expect(pc).toHaveLength(PASSCODE_LENGTH)
61
+ for (const c of pc) expect(PASSCODE_ALPHABET).toContain(c)
62
+ })
63
+ it('does not repeat itself', () => {
64
+ const seen = new Set(Array.from({ length: 50 }, () => generatePasscode()))
65
+ expect(seen.size).toBe(50)
66
+ })
67
+ it('mints what the strength check accepts', () => {
68
+ expect(() => assertPasscodeStrength(generatePasscode())).not.toThrow()
69
+ })
70
+ })
71
+
72
+ describe('assertPasscodeStrength', () => {
73
+ it('refuses a six-digit number — the case the skill prose allowed', () => {
74
+ expect(() => assertPasscodeStrength('123456')).toThrow(PasscodeTooWeakError)
75
+ })
76
+ it('refuses with a named reason rather than a bare Error', () => {
77
+ try {
78
+ assertPasscodeStrength('123456')
79
+ throw new Error('should have refused')
80
+ } catch (e) {
81
+ expect(e).toBeInstanceOf(PasscodeTooWeakError)
82
+ expect((e as PasscodeTooWeakError).reason).toBe('passcode-too-weak')
83
+ }
84
+ })
85
+ it('refuses one character short', () => {
86
+ expect(() => assertPasscodeStrength('A'.repeat(PASSCODE_LENGTH - 1))).toThrow(
87
+ PasscodeTooWeakError,
88
+ )
89
+ })
90
+ it('refuses a character outside the alphabet', () => {
91
+ expect(() => assertPasscodeStrength('!'.repeat(PASSCODE_LENGTH))).toThrow(PasscodeTooWeakError)
92
+ })
93
+ it('refuses the ambiguous characters the alphabet excludes', () => {
94
+ expect(() => assertPasscodeStrength('I'.repeat(PASSCODE_LENGTH))).toThrow(PasscodeTooWeakError)
95
+ })
96
+ it('refuses an empty passcode', () => {
97
+ expect(() => assertPasscodeStrength('')).toThrow(PasscodeTooWeakError)
98
+ })
99
+ it('checks structure only, and does NOT claim to measure entropy', () => {
100
+ // This documents a limitation deliberately. A repeated character has ~no
101
+ // entropy yet passes, because length and alphabet are all a check on an
102
+ // arbitrary string can verify. Entropy comes from generatePasscode alone.
103
+ // If this ever throws, the check has grown a claim it cannot support.
104
+ expect(() => assertPasscodeStrength('A'.repeat(PASSCODE_LENGTH))).not.toThrow()
28
105
  })
29
106
  })
30
107
 
31
108
  describe('hashPasscode', () => {
32
- it('is deterministic for the same passcode, salt and iterations', async () => {
33
- const a = await hashPasscode('hunter2', SALT, 1000)
34
- const b = await hashPasscode('hunter2', SALT, 1000)
35
- expect(a).toBe(b)
36
- expect(a).toMatch(/^[0-9a-f]{64}$/)
109
+ it('is deterministic for the same passcode and salt', async () => {
110
+ const pc = generatePasscode()
111
+ expect(await hashPasscode(pc, SALT)).toBe(await hashPasscode(pc, SALT))
112
+ })
113
+ it('returns 64 hex characters', async () => {
114
+ expect(await hashPasscode(generatePasscode(), SALT)).toMatch(/^[0-9a-f]{64}$/)
37
115
  })
38
116
  it('differs for a different passcode', async () => {
39
- const a = await hashPasscode('hunter2', SALT, 1000)
40
- const b = await hashPasscode('hunter3', SALT, 1000)
41
- expect(a).not.toBe(b)
117
+ expect(await hashPasscode(generatePasscode(), SALT)).not.toBe(
118
+ await hashPasscode(generatePasscode(), SALT),
119
+ )
120
+ })
121
+ it('differs for the same passcode under a different salt', async () => {
122
+ const pc = generatePasscode()
123
+ const other = '0f0e0d0c0b0a09080706050403020100'
124
+ expect(await hashPasscode(pc, SALT)).not.toBe(await hashPasscode(pc, other))
42
125
  })
43
- it('differs for a different salt', async () => {
44
- const a = await hashPasscode('hunter2', SALT, 1000)
45
- const b = await hashPasscode('hunter2', 'ffffffffffffffffffffffffffffffff', 1000)
46
- expect(a).not.toBe(b)
126
+ it('refuses to hash a weak passcode rather than hashing it anyway', async () => {
127
+ // The mutation check: revert assertPasscodeStrength's call here and this
128
+ // fails. Without it the check is decoration and the fast hash is unsafe.
129
+ await expect(hashPasscode('123456', SALT)).rejects.toBeInstanceOf(PasscodeTooWeakError)
130
+ })
131
+ it('rejects a salt that is not hex', async () => {
132
+ await expect(hashPasscode(generatePasscode(), 'nothex')).rejects.toThrow()
47
133
  })
48
134
  })
49
135
 
50
136
  describe('verifyPasscode', () => {
51
137
  it('accepts the right passcode', async () => {
52
- const h = await hashPasscode('hunter2', SALT, 1000)
53
- expect(await verifyPasscode('hunter2', SALT, h, 1000)).toBe(true)
138
+ const pc = generatePasscode()
139
+ expect(await verifyPasscode(pc, SALT, await hashPasscode(pc, SALT))).toBe(true)
54
140
  })
55
141
  it('rejects the wrong passcode', async () => {
56
- const h = await hashPasscode('hunter2', SALT, 1000)
57
- expect(await verifyPasscode('hunter3', SALT, h, 1000)).toBe(false)
142
+ const stored = await hashPasscode(generatePasscode(), SALT)
143
+ expect(await verifyPasscode(generatePasscode(), SALT, stored)).toBe(false)
144
+ })
145
+ it('rejects rather than throws when the stored hash is malformed', async () => {
146
+ expect(await verifyPasscode(generatePasscode(), SALT, 'nothex')).toBe(false)
147
+ })
148
+ it('rejects a weak passcode at sign-in without throwing', async () => {
149
+ // A row hashed before the check existed must not crash the auth route.
150
+ expect(await verifyPasscode('123456', SALT, '0'.repeat(64))).toBe(false)
151
+ })
152
+ it('accepts a lowercase transcription of the passcode', async () => {
153
+ // The whole reason the alphabet excludes I, L, O and U is that a person
154
+ // transcribes this by hand. Crockford is case-insensitive on decode for
155
+ // that same reason. The mutation check: drop .toUpperCase() from
156
+ // verifyPasscode and this goes red.
157
+ const pc = generatePasscode()
158
+ const stored = await hashPasscode(pc, SALT)
159
+ expect(await verifyPasscode(pc.toLowerCase(), SALT, stored)).toBe(true)
160
+ })
161
+ it('accepts a mixed-case transcription of the passcode', async () => {
162
+ const pc = generatePasscode()
163
+ const stored = await hashPasscode(pc, SALT)
164
+ const mixed = [...pc].map((c, i) => (i % 2 ? c.toLowerCase() : c)).join('')
165
+ expect(await verifyPasscode(mixed, SALT, stored)).toBe(true)
58
166
  })
59
- it('rejects a malformed stored hash without throwing', async () => {
60
- expect(await verifyPasscode('hunter2', SALT, 'not-hex', 1000)).toBe(false)
167
+ it('still rejects the wrong passcode, lowercase included', async () => {
168
+ // Widening the accepted case must not widen the accepted passcode.
169
+ const stored = await hashPasscode(generatePasscode(), SALT)
170
+ expect(await verifyPasscode(generatePasscode().toLowerCase(), SALT, stored)).toBe(false)
171
+ })
172
+ it('returns false rather than throwing when the passcode is not a string', async () => {
173
+ // .toUpperCase() throws on a non-string, so it sits INSIDE this function's
174
+ // try. Move it out and this goes red. That placement is what keeps the
175
+ // documented never-throw contract true for the input class it covers.
176
+ // @ts-expect-error deliberately passing a non-string
177
+ expect(await verifyPasscode(null, SALT, '0'.repeat(64))).toBe(false)
61
178
  })
62
179
  })
63
180
 
64
- describe('randomSaltHex', () => {
65
- it('renders bytes as lowercase hex', () => {
66
- expect(randomSaltHex(new Uint8Array([0, 1, 255]))).toBe('0001ff')
181
+ describe('constantTimeEqual', () => {
182
+ it('is true for identical arrays', () => {
183
+ expect(constantTimeEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 3]))).toBe(true)
184
+ })
185
+ it('is false when a byte differs', () => {
186
+ expect(constantTimeEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 4]))).toBe(false)
187
+ })
188
+ it('is false for different lengths, without throwing', () => {
189
+ expect(constantTimeEqual(new Uint8Array([1, 2]), new Uint8Array([1, 2, 3]))).toBe(false)
190
+ })
191
+ it('compares every byte even after a mismatch (no early return)', () => {
192
+ // A first-byte mismatch and a last-byte mismatch must both be false.
193
+ // This does not measure timing; it pins that the function does not
194
+ // short-circuit in a way a rewrite could reintroduce.
195
+ expect(constantTimeEqual(new Uint8Array([9, 2, 3]), new Uint8Array([1, 2, 3]))).toBe(false)
196
+ expect(constantTimeEqual(new Uint8Array([1, 2, 9]), new Uint8Array([1, 2, 3]))).toBe(false)
67
197
  })
68
198
  })
69
199
 
70
- describe('PBKDF2_ITERATIONS', () => {
71
- it('is at least the OWASP floor for PBKDF2-SHA256', () => {
72
- expect(PBKDF2_ITERATIONS).toBeGreaterThanOrEqual(600_000)
200
+ describe('randomSaltHex', () => {
201
+ it('renders bytes as lowercase hex, zero-padded', () => {
202
+ expect(randomSaltHex(new Uint8Array([0, 15, 16, 255]))).toBe('000f10ff')
73
203
  })
74
204
  })
@@ -0,0 +1,207 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { execFile } from 'node:child_process'
3
+ import { promisify } from 'node:util'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { dirname, resolve } from 'node:path'
6
+ import {
7
+ PASSCODE_ALPHABET,
8
+ PASSCODE_LENGTH,
9
+ verifyPasscode,
10
+ } from '../../skills/data-portal/template/functions/api/_lib/passcode.mjs'
11
+
12
+ const run = promisify(execFile)
13
+ const SCRIPT = resolve(dirname(fileURLToPath(import.meta.url)), '../../bin/portal-enrol.mjs')
14
+
15
+ const enrol = (args: string[]) => run('node', [SCRIPT, ...args])
16
+
17
+ /** The script's contract: stdout is SQL and nothing else, so it pipes straight
18
+ * into `wrangler d1 execute --file=-`; the passcode goes to stderr, where the
19
+ * operator still sees it on their terminal but a pipe never carries it. The two
20
+ * streams are the whole point — a passcode on stdout would be fed to wrangler,
21
+ * which echoes the failing statement into its error output. */
22
+ function parse(r: { stdout: string; stderr: string }) {
23
+ const passcode = /^passcode:\s*(\S+)$/m.exec(r.stderr)?.[1]
24
+ const sql = /^(INSERT INTO people .*)$/m.exec(r.stdout)?.[1]
25
+ return { passcode, sql }
26
+ }
27
+
28
+ const valuesOf = (sql: string) =>
29
+ /VALUES \('([^']*)', '((?:[^']|'')*)', '([0-9a-f]+)', '([0-9a-f]+)', '([^']*)'\)/.exec(sql)!
30
+
31
+ /** Apply the emitted SQL to a real SQLite database built from the template's own
32
+ * schema.sql, the way `wrangler d1 execute` applies it to D1. A fake would model
33
+ * the constraint we are trying to prove we satisfy, which is how 1689 shipped a
34
+ * UNIQUE-violating upload: its fake accepted duplicates the schema rejected. */
35
+ async function d1(dir: string) {
36
+ const { mkdtemp } = await import('node:fs/promises')
37
+ const { tmpdir } = await import('node:os')
38
+ const root = await mkdtemp(resolve(tmpdir(), dir))
39
+ const db = resolve(root, 'portal.db')
40
+ const schema = resolve(
41
+ dirname(fileURLToPath(import.meta.url)),
42
+ '../../skills/data-portal/template/schema.sql',
43
+ )
44
+ // SQL as an argv entry, not stdin: execFile has no `input` option. The schema
45
+ // goes via `.read` because it opens with a `--` comment, which sqlite3 would
46
+ // otherwise parse as a CLI option.
47
+ await run('sqlite3', [db, `.read ${schema}`])
48
+ return {
49
+ exec: (sql: string) => run('sqlite3', [db, sql]),
50
+ query: async (sql: string) => (await run('sqlite3', [db, sql])).stdout.trim(),
51
+ }
52
+ }
53
+
54
+ describe('portal-enrol', () => {
55
+ it('prints a passcode at the pinned length from the pinned alphabet', async () => {
56
+ const r = await enrol(['--owner', 'alice', '--name', 'Alice'])
57
+ const { passcode } = parse(r)
58
+ expect(passcode).toHaveLength(PASSCODE_LENGTH)
59
+ for (const c of passcode!) expect(PASSCODE_ALPHABET).toContain(c)
60
+ })
61
+
62
+ it('emits a hash the portal will actually accept for that passcode', async () => {
63
+ // This is the test that matters. It proves enrolment and the Worker agree,
64
+ // which is the whole reason the hash has one implementation rather than a
65
+ // twin in this script.
66
+ const r = await enrol(['--owner', 'alice', '--name', 'Alice'])
67
+ const { passcode, sql } = parse(r)
68
+ const [, , , salt, hash] = valuesOf(sql!)
69
+ expect(await verifyPasscode(passcode!, salt, hash)).toBe(true)
70
+ })
71
+
72
+ it('emits a hash that rejects a different passcode', async () => {
73
+ const a = await enrol(['--owner', 'alice', '--name', 'Alice'])
74
+ const [, , , salt, hash] = valuesOf(parse(a).sql!)
75
+ const b = await enrol(['--owner', 'bob', '--name', 'Bob'])
76
+ expect(await verifyPasscode(parse(b).passcode!, salt, hash)).toBe(false)
77
+ })
78
+
79
+ it('never puts the passcode in the SQL', async () => {
80
+ // A passcode reaching D1 or a log in plaintext is the one thing enrolment
81
+ // must never do.
82
+ const r = await enrol(['--owner', 'alice', '--name', 'Alice'])
83
+ const { passcode, sql } = parse(r)
84
+ expect(sql).not.toContain(passcode!)
85
+ })
86
+
87
+ it('does not repeat a passcode across enrolments', async () => {
88
+ const a = await enrol(['--owner', 'alice', '--name', 'Alice'])
89
+ const b = await enrol(['--owner', 'alice', '--name', 'Alice'])
90
+ expect(parse(a).passcode).not.toBe(parse(b).passcode)
91
+ })
92
+
93
+ it('does not repeat a salt across enrolments', async () => {
94
+ const saltOf = async (owner: string) => {
95
+ const r = await enrol(['--owner', owner, '--name', owner])
96
+ return valuesOf(parse(r).sql!)[3]
97
+ }
98
+ expect(await saltOf('alice')).not.toBe(await saltOf('bob'))
99
+ })
100
+
101
+ it('refuses an ownerId the isolation gate would reject', async () => {
102
+ // `alice` and `alice/2024` co-enrolled would breach the prefix isolation
103
+ // rule, so enrolment fails closed on the same charset the gate enforces.
104
+ await expect(enrol(['--owner', 'alice/2024', '--name', 'Alice'])).rejects.toThrow()
105
+ await expect(enrol(['--owner', 'Alice', '--name', 'Alice'])).rejects.toThrow()
106
+ await expect(enrol(['--owner', '../etc', '--name', 'Alice'])).rejects.toThrow()
107
+ })
108
+
109
+ it('refuses a missing owner or name', async () => {
110
+ await expect(enrol(['--name', 'Alice'])).rejects.toThrow()
111
+ await expect(enrol(['--owner', 'alice'])).rejects.toThrow()
112
+ await expect(enrol([])).rejects.toThrow()
113
+ })
114
+
115
+ it('escapes a quote in the name rather than breaking the statement', async () => {
116
+ const r = await enrol(['--owner', 'alice', '--name', "O'Brien"])
117
+ expect(parse(r).sql).toContain("'O''Brien'")
118
+ })
119
+
120
+ it('writes the row the schema declares, in order', async () => {
121
+ const r = await enrol(['--owner', 'alice', '--name', 'Alice'])
122
+ const { sql } = parse(r)
123
+ expect(sql).toContain('INSERT INTO people (ownerId, name, salt, hash, createdAt)')
124
+ const [, owner, name, salt, hash, createdAt] = valuesOf(sql!)
125
+ expect(owner).toBe('alice')
126
+ expect(name).toBe('Alice')
127
+ expect(salt).toMatch(/^[0-9a-f]{32}$/)
128
+ expect(hash).toMatch(/^[0-9a-f]{64}$/)
129
+ expect(Number.isNaN(Date.parse(createdAt))).toBe(false)
130
+ })
131
+
132
+ it('puts SQL and only SQL on stdout, so the documented pipe works', async () => {
133
+ // SKILL.md tells the agent to pipe stdout into `wrangler d1 execute`. Anything
134
+ // else on stdout goes into wrangler as SQL — and a passcode there would be
135
+ // echoed back in wrangler's syntax error, which is the one place a plaintext
136
+ // passcode must never reach.
137
+ const r = await enrol(['--owner', 'alice', '--name', 'Alice'])
138
+ expect(r.stdout.trim()).toMatch(/^INSERT INTO people [^\n]*;$/)
139
+ expect(r.stdout).not.toContain('passcode')
140
+ })
141
+
142
+ it('keeps the passcode off stdout entirely, on stderr where a pipe cannot take it', async () => {
143
+ const r = await enrol(['--owner', 'alice', '--name', 'Alice'])
144
+ const { passcode } = parse(r)
145
+ expect(passcode).toHaveLength(PASSCODE_LENGTH)
146
+ expect(r.stdout).not.toContain(passcode!)
147
+ })
148
+
149
+ it('rejects a newline in the name, which would break the pipeable contract', async () => {
150
+ await expect(enrol(['--owner', 'alice', '--name', 'Bob\nDROP'])).rejects.toThrow()
151
+ })
152
+
153
+ describe('against a real SQLite database built from schema.sql', () => {
154
+ it('applies cleanly', async () => {
155
+ const db = await d1('enrol-apply-')
156
+ const r = await enrol(['--owner', 'alice', '--name', 'Alice'])
157
+ await db.exec(parse(r).sql!)
158
+ expect(await db.query('SELECT ownerId, name FROM people;')).toBe('alice|Alice')
159
+ })
160
+
161
+ it('rotates a passcode by re-running, which is the ONLY sanctioned rotation path', async () => {
162
+ // SKILL.md: "Rotating a passcode is a re-run of the script for that person."
163
+ // A bare INSERT cannot do that — ownerId is TEXT PRIMARY KEY, so the second
164
+ // run dies on a UNIQUE violation in a SEPARATE process, AFTER the script has
165
+ // already printed a new passcode and exited 0. The operator hands out a
166
+ // passcode that never worked while the leaked one still does, and pcCheck
167
+ // revocation never fires. Rotation is exactly the case where a remedy that
168
+ // silently no-ops is worse than no remedy.
169
+ const db = await d1('enrol-rotate-')
170
+
171
+ const first = await enrol(['--owner', 'alice', '--name', 'Alice'])
172
+ await db.exec(parse(first).sql!)
173
+
174
+ const second = await enrol(['--owner', 'alice', '--name', 'Alice'])
175
+ await db.exec(parse(second).sql!)
176
+
177
+ const [salt, hash] = (await db.query("SELECT salt, hash FROM people WHERE ownerId='alice';"))
178
+ .split('|')
179
+ expect(await verifyPasscode(parse(second).passcode!, salt, hash)).toBe(true)
180
+ expect(await verifyPasscode(parse(first).passcode!, salt, hash)).toBe(false)
181
+ })
182
+
183
+ it('rotating leaves exactly one row, and does not reset createdAt', async () => {
184
+ // createdAt is when the person was enrolled. Rotation is not creation, so
185
+ // it does not move; nothing reads it, but a field that lies about what it
186
+ // holds is the defect this component keeps being reviewed for.
187
+ const db = await d1('enrol-one-row-')
188
+ const first = await enrol(['--owner', 'alice', '--name', 'Alice'])
189
+ await db.exec(parse(first).sql!)
190
+ const createdAt = await db.query("SELECT createdAt FROM people WHERE ownerId='alice';")
191
+
192
+ await db.exec(parse(await enrol(['--owner', 'alice', '--name', 'Alice'])).sql!)
193
+
194
+ expect(await db.query('SELECT COUNT(*) FROM people;')).toBe('1')
195
+ expect(await db.query("SELECT createdAt FROM people WHERE ownerId='alice';")).toBe(createdAt)
196
+ })
197
+
198
+ it('stores a crafted name as data rather than executing it', async () => {
199
+ const db = await d1('enrol-inject-')
200
+ const r = await enrol(['--owner', 'alice', '--name', "x'); DROP TABLE people; --"])
201
+ await db.exec(parse(r).sql!)
202
+ expect(await db.query("SELECT name FROM people WHERE ownerId='alice';")).toBe(
203
+ "x'); DROP TABLE people; --",
204
+ )
205
+ })
206
+ })
207
+ })
@@ -1,14 +1,14 @@
1
- import { initStderrTee } from "../../../../lib/mcp-stderr-tee/dist/index.js";
2
- initStderrTee("cloudflare");
3
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
3
  // The Cloudflare plugin exposes zero agent-facing tools. Every
6
4
  // Cloudflare operation is the agent invoking `cloudflared` directly via
7
5
  // the Bash tool, following the runbook in
8
6
  // platform/plugins/cloudflare/references/manual-setup.md, with
9
- // cloudflared stdout/stderr streamed verbatim into the PTY. The MCP
10
- // server process still spawns for lifecycle and stderr-tee consistency
11
- // with other plugins, but its tool list is empty by design.
7
+ // cloudflared stdout/stderr streamed verbatim into the PTY. The plugin
8
+ // declares `mcp-manifest: skip` and no `mcp:` block, so this entry is never
9
+ // spawned as an MCP server: only the generated .claude-plugin/plugin.json
10
+ // mcpServers block makes Claude Code spawn one (tool-registration-drift.ts).
11
+ // Task 1721 removed its unreachable stderr-tee call.
12
12
  const server = new McpServer({
13
13
  name: "cloudflare",
14
14
  version: "0.4.0",
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,8CAA8C,CAAC;AAC7E,aAAa,CAAC,YAAY,CAAC,CAAC;AAE5B,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACpE,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AAEjF,+DAA+D;AAC/D,wEAAwE;AACxE,0CAA0C;AAC1C,+DAA+D;AAC/D,oEAAoE;AACpE,uEAAuE;AACvE,4DAA4D;AAE5D,MAAM,MAAM,GAAG,IAAI,SAAS,CAAC;IAC3B,IAAI,EAAE,YAAY;IAClB,OAAO,EAAE,OAAO;CACjB,CAAC,CAAC;AAEH,KAAK,UAAU,IAAI;IACjB,MAAM,SAAS,GAAG,IAAI,oBAAoB,EAAE,CAAC;IAC7C,MAAM,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;AAClC,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;IACnB,OAAO,CAAC,KAAK,CAAC,2BAA2B,GAAG,EAAE,CAAC,CAAC;IAChD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACpE,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AAEjF,+DAA+D;AAC/D,wEAAwE;AACxE,0CAA0C;AAC1C,+DAA+D;AAC/D,uEAAuE;AACvE,4EAA4E;AAC5E,0EAA0E;AAC1E,6EAA6E;AAC7E,qDAAqD;AAErD,MAAM,MAAM,GAAG,IAAI,SAAS,CAAC;IAC3B,IAAI,EAAE,YAAY;IAClB,OAAO,EAAE,OAAO;CACjB,CAAC,CAAC;AAEH,KAAK,UAAU,IAAI;IACjB,MAAM,SAAS,GAAG,IAAI,oBAAoB,EAAE,CAAC;IAC7C,MAAM,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;AAClC,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;IACnB,OAAO,CAAC,KAAK,CAAC,2BAA2B,GAAG,EAAE,CAAC,CAAC;IAChD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
@@ -8,7 +8,7 @@
8
8
  "build": "tsc",
9
9
  "start": "node dist/index.js",
10
10
  "test": "vitest run",
11
- "typecheck:templates": "tsc --noEmit --strict --target es2022 --module esnext --moduleResolution bundler --lib es2022,dom ../skills/*/template/functions/api/*.ts ../skills/*/template/functions/api/_lib/*.ts"
11
+ "typecheck:templates": "tsc --noEmit --strict --allowJs --checkJs --target es2022 --module esnext --moduleResolution bundler --lib es2022,dom ../skills/*/template/functions/api/*.ts ../skills/*/template/functions/api/_lib/*.ts ../skills/*/template/functions/api/_lib/*.mjs"
12
12
  },
13
13
  "dependencies": {
14
14
  "@modelcontextprotocol/sdk": "^1.12.1"
@@ -33,6 +33,8 @@ If the site captures form data, add the `[[d1_databases]]` binding from `d1-data
33
33
 
34
34
  ## 3. Load the reused deploy token
35
35
 
36
+ > **On a multi-tenant install, a client account does not run this step at all.** After the 1631 storage isolation, a client's `cloudflare.env` carries no master, and `cf-token.sh` denies it the `pages` scope (`reason=not-house`) — correctly, since that master is account-wide. A client account publishes with the `storage-pages-deploy` tool, which runs the deploy house-side; the project must already be recorded to the account by the house (`storage-pages-adopt`), because the project-to-folder mapping is not derivable and is never guessed. The rest of this section applies to the **house account and single-tenant installs**, where the master still lives in the account secrets file. If you are a client account and you reached a `not-house` denial here, that is this branch, not a broken token: use the tool.
37
+
36
38
  A Pages deploy needs **Account · Cloudflare Pages · Edit**. If the site has Pages Functions hitting D1, the token also needs **Account · D1 · Edit** (the single most common breakage — see `d1-data-capture.md`). The `pages` scope resolves the stable `<brand>-pages-d1` token, which carries both. Resolve it with the deterministic helper, which mints it once only if absent and reuses it thereafter. This is the one command; do not source the secrets file and hand the resulting `CLOUDFLARE_API_TOKEN` to `wrangler` — that variable holds the **master**, not the per-scope token, and `wrangler pages` then returns `Authentication error [code: 10000]`:
37
39
 
38
40
  ```bash
@@ -56,7 +56,7 @@ The booking-specific schema ships with the template: `schema.sql` creates both t
56
56
 
57
57
  ## Deploy through site-deploy
58
58
 
59
- Hand the assembled tree to `cloudflare:site-deploy`: it builds nothing for a static tree, runs `wrangler pages deploy public --project-name <project> --branch=main`, attaches the booking domain (creating the DNS record with the `<brand>-dns` token when the attach leaves validation pending), and reuses the same per-scope token. Everything in `site-deploy`'s auth, custom-domain, and DNS guidance applies unchanged.
59
+ Hand the assembled tree to `cloudflare:site-deploy`: it builds nothing for a static tree, runs `wrangler pages deploy public --project-name <project> --branch=main`, attaches the booking domain (creating the DNS record with the `<brand>-dns` token when the attach leaves validation pending), and reuses the same per-scope token. Everything in `site-deploy`'s auth, custom-domain, and DNS guidance applies unchanged — including its multi-tenant branch: "admin-run" means a `role:"admin"` session, which on a multi-tenant install is **not** the same thing as the house. A client account's admin session is denied the `pages` scope (`reason=not-house`) and publishes with `storage-pages-deploy` instead.
60
60
 
61
61
  ## Done-gate
62
62
 
@@ -43,11 +43,25 @@ Collect from the operator the portal's custom domain and the list of people, the
43
43
  `<accountDir>/data-portal.json` carrying `portalDomain`, `portalDbName`, `bucketName`, and a
44
44
  `people` array of `{ name, ownerId, prefix }`.
45
45
 
46
- For each person, generate a high-entropy passcode, print it to the operator **once**, and insert the
47
- person into D1 with only a PBKDF2 salt and hash. **A passcode is never stored in plaintext** — not
48
- in `data-portal.json`, not in a log line, nowhere on the device. The operator conveys it to the
49
- person out of band. Rotating a passcode is a re-run of this step for that person; there is no
50
- separate editor.
46
+ For each person:
47
+
48
+ ```bash
49
+ node platform/plugins/cloudflare/bin/portal-enrol.mjs --owner <ownerId> --name <name> \
50
+ | npx wrangler d1 execute <portalDbName> --remote --file=-
51
+ ```
52
+
53
+ The script mints the passcode and prints it **once to stderr**, so it reaches the operator's terminal
54
+ and never the pipe; stdout carries the `INSERT` alone. Do not hand-roll this: the passcode's length
55
+ and alphabet are pinned in `template/functions/api/_lib/passcode.mjs`, the script is what applies
56
+ them, and a passcode minted any other way is unpinned by definition. The script refuses an `ownerId`
57
+ the isolation gate would reject, so a bad name fails here rather than after the row lands.
58
+
59
+ The statement is an upsert on `ownerId`, which is what makes rotation a re-run rather than a
60
+ constraint error.
61
+
62
+ **A passcode is never stored in plaintext** — not in `data-portal.json`, not in a log line, nowhere
63
+ on the device. The operator conveys it to the person out of band. Rotating a passcode is a re-run of
64
+ the script for that person; there is no separate editor.
51
65
 
52
66
  Rotation also revokes: a session records a digest of the person's passcode hash at sign-in, and the
53
67
  session lookup requires it to still match, so re-running enrolment kills every live session for that
@@ -74,23 +88,12 @@ Hand the assembled tree to `cloudflare:site-deploy`. Both bindings — D1 and R2
74
88
  config in `wrangler.toml`, carried in the deploy payload under the Pages scope; the deploy needs no
75
89
  R2 token. Only the bucket *creation* in step 1 needs R2 write, and the broker holds that.
76
90
 
77
- ## The portal needs a paid Cloudflare plan
78
-
79
- Check this before promising a date. Signing in costs one PBKDF2 derivation at the OWASP floor of
80
- 600,000 iterations, measured at roughly 250ms of CPU. Workers Free allows **10ms of CPU per
81
- request**, so on a free plan authentication cannot succeed — and no OWASP-compliant setting fits
82
- either, since even 100,000 iterations costs about 40ms. Paid plans allow far more and are fine.
83
-
84
- The cost is tunable without a migration if it ever needs to be: `iterations` is stored per person
85
- row, so it is the enrolment-time value that binds, not a constant baked into the deployed code.
86
-
87
91
  ## Done-gate
88
92
 
89
93
  The portal is not done until all five hold on the live domain:
90
94
 
91
95
  1. A cache-busted request to the portal domain returns `HTTP/2 200`.
92
- 2. Signing in with the right passcode returns `200` **within the CPU budget** rather than timing
93
- out — this is the check that catches a free-plan deployment, and it fails nowhere else.
96
+ 2. Signing in with the right passcode returns `200`.
94
97
  3. A wrong passcode returns `401`; the **sixth** rapid wrong passcode returns `429` (five are
95
98
  allowed per fifteen minutes, per person, per IP).
96
99
  4. An upload as one person returns `200`, and the object is present in R2 under that person's
@@ -100,8 +103,7 @@ The portal is not done until all five hold on the live domain:
100
103
 
101
104
  A `502` on `/api/upload` with `op=r2-put result=failed` on every attempt is the expected signature of
102
105
  an R2 binding that did not deploy — check `wrangler.toml` reached the tree with its placeholder
103
- filled. An auth request that hangs or 500s while everything else works is the free-plan signature
104
- above, not a code fault.
106
+ filled.
105
107
 
106
108
  Uploads are capped at 25 MiB and a larger one returns `413` with `result=too-large`.
107
109