@rubytech/create-maxy-code 0.1.459 → 0.1.461

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 (189) 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/storage-broker/dist/__tests__/cf-exec.test.js +116 -6
  9. package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js.map +1 -1
  10. package/payload/platform/lib/storage-broker/dist/__tests__/object-limits.test.js +20 -0
  11. package/payload/platform/lib/storage-broker/dist/__tests__/object-limits.test.js.map +1 -1
  12. package/payload/platform/lib/storage-broker/dist/__tests__/resource-limits.test.d.ts +2 -0
  13. package/payload/platform/lib/storage-broker/dist/__tests__/resource-limits.test.d.ts.map +1 -0
  14. package/payload/platform/lib/storage-broker/dist/__tests__/resource-limits.test.js +41 -0
  15. package/payload/platform/lib/storage-broker/dist/__tests__/resource-limits.test.js.map +1 -0
  16. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts +1 -0
  17. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts.map +1 -1
  18. package/payload/platform/lib/storage-broker/dist/cf-exec.js +116 -48
  19. package/payload/platform/lib/storage-broker/dist/cf-exec.js.map +1 -1
  20. package/payload/platform/lib/storage-broker/dist/index.d.ts +1 -0
  21. package/payload/platform/lib/storage-broker/dist/index.d.ts.map +1 -1
  22. package/payload/platform/lib/storage-broker/dist/index.js +1 -0
  23. package/payload/platform/lib/storage-broker/dist/index.js.map +1 -1
  24. package/payload/platform/lib/storage-broker/dist/object-limits.d.ts +27 -0
  25. package/payload/platform/lib/storage-broker/dist/object-limits.d.ts.map +1 -1
  26. package/payload/platform/lib/storage-broker/dist/object-limits.js +27 -7
  27. package/payload/platform/lib/storage-broker/dist/object-limits.js.map +1 -1
  28. package/payload/platform/lib/storage-broker/dist/resource-limits.d.ts +102 -0
  29. package/payload/platform/lib/storage-broker/dist/resource-limits.d.ts.map +1 -0
  30. package/payload/platform/lib/storage-broker/dist/resource-limits.js +105 -0
  31. package/payload/platform/lib/storage-broker/dist/resource-limits.js.map +1 -0
  32. package/payload/platform/lib/storage-broker/src/__tests__/cf-exec.test.ts +130 -7
  33. package/payload/platform/lib/storage-broker/src/__tests__/object-limits.test.ts +30 -0
  34. package/payload/platform/lib/storage-broker/src/__tests__/resource-limits.test.ts +49 -0
  35. package/payload/platform/lib/storage-broker/src/cf-exec.ts +129 -56
  36. package/payload/platform/lib/storage-broker/src/index.ts +1 -0
  37. package/payload/platform/lib/storage-broker/src/object-limits.ts +26 -6
  38. package/payload/platform/lib/storage-broker/src/resource-limits.ts +105 -0
  39. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +5 -4
  40. package/payload/platform/plugins/cloudflare/PLUGIN.md +3 -1
  41. package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +96 -0
  42. package/payload/platform/plugins/cloudflare/mcp/__tests__/auth-route.test.ts +220 -0
  43. package/payload/platform/plugins/cloudflare/mcp/__tests__/authorize.test.ts +201 -0
  44. package/payload/platform/plugins/cloudflare/mcp/__tests__/file-routes.test.ts +178 -0
  45. package/payload/platform/plugins/cloudflare/mcp/__tests__/harness.test.ts +32 -0
  46. package/payload/platform/plugins/cloudflare/mcp/__tests__/passcode.test.ts +204 -0
  47. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +207 -0
  48. package/payload/platform/plugins/cloudflare/mcp/__tests__/ratelimit.test.ts +77 -0
  49. package/payload/platform/plugins/cloudflare/mcp/__tests__/skill-contract.test.ts +46 -0
  50. package/payload/platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts +55 -0
  51. package/payload/platform/plugins/cloudflare/mcp/__tests__/upload-route.test.ts +255 -0
  52. package/payload/platform/plugins/cloudflare/mcp/package.json +5 -2
  53. package/payload/platform/plugins/cloudflare/references/r2-object-storage.md +46 -0
  54. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +125 -0
  55. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/authorize.ts +32 -0
  56. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/log.ts +12 -0
  57. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/passcode.mjs +193 -0
  58. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/ratelimit.ts +55 -0
  59. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/session.ts +85 -0
  60. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/types.ts +44 -0
  61. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/auth.ts +119 -0
  62. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/delete.ts +89 -0
  63. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +70 -0
  64. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +41 -0
  65. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/upload.ts +151 -0
  66. package/payload/platform/plugins/cloudflare/skills/data-portal/template/public/index.html +42 -0
  67. package/payload/platform/plugins/cloudflare/skills/data-portal/template/public/portal.css +113 -0
  68. package/payload/platform/plugins/cloudflare/skills/data-portal/template/public/portal.js +155 -0
  69. package/payload/platform/plugins/cloudflare/skills/data-portal/template/schema.sql +73 -0
  70. package/payload/platform/plugins/cloudflare/skills/data-portal/template/wrangler.toml +21 -0
  71. package/payload/platform/plugins/cloudflare/skills/site-deploy/SKILL.md +8 -0
  72. package/payload/platform/plugins/docs/references/outlook-guide.md +3 -3
  73. package/payload/platform/plugins/docs/references/voice-mirror-guide.md +1 -0
  74. package/payload/platform/plugins/filesystem/PLUGIN.md +0 -1
  75. package/payload/platform/plugins/filesystem/mcp/dist/lib/scope.d.ts +14 -10
  76. package/payload/platform/plugins/filesystem/mcp/dist/lib/scope.d.ts.map +1 -1
  77. package/payload/platform/plugins/filesystem/mcp/dist/lib/scope.js +14 -10
  78. package/payload/platform/plugins/filesystem/mcp/dist/lib/scope.js.map +1 -1
  79. package/payload/platform/plugins/outlook/.claude-plugin/plugin.json +1 -1
  80. package/payload/platform/plugins/outlook/PLUGIN.md +3 -5
  81. package/payload/platform/plugins/outlook/mcp/dist/__tests__/detach.test.d.ts +2 -0
  82. package/payload/platform/plugins/outlook/mcp/dist/__tests__/detach.test.d.ts.map +1 -0
  83. package/payload/platform/plugins/outlook/mcp/dist/__tests__/detach.test.js +75 -0
  84. package/payload/platform/plugins/outlook/mcp/dist/__tests__/detach.test.js.map +1 -0
  85. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-removal.test.d.ts +2 -0
  86. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-removal.test.d.ts.map +1 -0
  87. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-removal.test.js +187 -0
  88. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-removal.test.js.map +1 -0
  89. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit.test.js +1 -1
  90. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit.test.js.map +1 -1
  91. package/payload/platform/plugins/outlook/mcp/dist/index.js +3 -2
  92. package/payload/platform/plugins/outlook/mcp/dist/index.js.map +1 -1
  93. package/payload/platform/plugins/outlook/mcp/dist/lib/attachment-list.d.ts +43 -0
  94. package/payload/platform/plugins/outlook/mcp/dist/lib/attachment-list.d.ts.map +1 -0
  95. package/payload/platform/plugins/outlook/mcp/dist/lib/attachment-list.js +65 -0
  96. package/payload/platform/plugins/outlook/mcp/dist/lib/attachment-list.js.map +1 -0
  97. package/payload/platform/plugins/outlook/mcp/dist/lib/detach.d.ts +65 -0
  98. package/payload/platform/plugins/outlook/mcp/dist/lib/detach.d.ts.map +1 -0
  99. package/payload/platform/plugins/outlook/mcp/dist/lib/detach.js +99 -0
  100. package/payload/platform/plugins/outlook/mcp/dist/lib/detach.js.map +1 -0
  101. package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.d.ts +6 -1
  102. package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.d.ts.map +1 -1
  103. package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.js.map +1 -1
  104. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.d.ts +42 -22
  105. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.d.ts.map +1 -1
  106. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.js +59 -23
  107. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.js.map +1 -1
  108. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.d.ts +15 -16
  109. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.d.ts.map +1 -1
  110. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.js +8 -41
  111. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.js.map +1 -1
  112. package/payload/platform/plugins/outlook/references/auth.md +1 -1
  113. package/payload/platform/plugins/outlook/references/graph-surfaces.md +7 -3
  114. package/payload/platform/plugins/outlook/skills/outlook/SKILL.md +1 -1
  115. package/payload/platform/plugins/storage-broker/PLUGIN.md +1 -1
  116. package/payload/platform/plugins/voice-mirror/PLUGIN.md +19 -7
  117. package/payload/platform/plugins/voice-mirror/mcp/dist/index.d.ts.map +1 -1
  118. package/payload/platform/plugins/voice-mirror/mcp/dist/index.js +35 -16
  119. package/payload/platform/plugins/voice-mirror/mcp/dist/index.js.map +1 -1
  120. package/payload/platform/plugins/voice-mirror/mcp/dist/lib/admin-user.d.ts +31 -0
  121. package/payload/platform/plugins/voice-mirror/mcp/dist/lib/admin-user.d.ts.map +1 -0
  122. package/payload/platform/plugins/voice-mirror/mcp/dist/lib/admin-user.js +87 -0
  123. package/payload/platform/plugins/voice-mirror/mcp/dist/lib/admin-user.js.map +1 -0
  124. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.d.ts.map +1 -1
  125. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.js +111 -12
  126. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.js.map +1 -1
  127. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-ingest-session-text.d.ts.map +1 -1
  128. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-ingest-session-text.js +2 -1
  129. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-ingest-session-text.js.map +1 -1
  130. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-record-feedback.d.ts.map +1 -1
  131. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-record-feedback.js +2 -1
  132. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-record-feedback.js.map +1 -1
  133. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-retrieve-conditioning.d.ts.map +1 -1
  134. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-retrieve-conditioning.js +54 -21
  135. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-retrieve-conditioning.js.map +1 -1
  136. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-tag-content.d.ts +2 -2
  137. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-tag-content.d.ts.map +1 -1
  138. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-tag-content.js +12 -3
  139. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-tag-content.js.map +1 -1
  140. package/payload/platform/plugins/voice-mirror/mcp/scripts/smoke.mjs +444 -0
  141. package/payload/platform/plugins/voice-mirror/skills/voice-mirror/SKILL.md +18 -2
  142. package/payload/platform/scripts/__tests__/logs-rotate.test.sh +256 -0
  143. package/payload/platform/scripts/__tests__/resume-tunnel.test.sh +79 -0
  144. package/payload/platform/scripts/logs-rotate.sh +204 -0
  145. package/payload/platform/scripts/resume-tunnel.sh +27 -0
  146. package/payload/platform/scripts/voice-author-key-audit.sh +155 -0
  147. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  148. package/payload/platform/services/claude-session-manager/dist/http-server.js +41 -5
  149. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  150. package/payload/platform/services/claude-session-manager/dist/index.d.ts +1 -0
  151. package/payload/platform/services/claude-session-manager/dist/index.d.ts.map +1 -1
  152. package/payload/platform/services/claude-session-manager/dist/index.js +22 -0
  153. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  154. package/payload/platform/services/claude-session-manager/dist/install-start-counter.d.ts +2 -0
  155. package/payload/platform/services/claude-session-manager/dist/install-start-counter.d.ts.map +1 -0
  156. package/payload/platform/services/claude-session-manager/dist/install-start-counter.js +12 -0
  157. package/payload/platform/services/claude-session-manager/dist/install-start-counter.js.map +1 -0
  158. package/payload/platform/services/claude-session-manager/dist/mcp-descriptor-resolve.d.ts +17 -0
  159. package/payload/platform/services/claude-session-manager/dist/mcp-descriptor-resolve.d.ts.map +1 -0
  160. package/payload/platform/services/claude-session-manager/dist/mcp-descriptor-resolve.js +88 -0
  161. package/payload/platform/services/claude-session-manager/dist/mcp-descriptor-resolve.js.map +1 -0
  162. package/payload/platform/services/claude-session-manager/dist/pty-census.d.ts +122 -9
  163. package/payload/platform/services/claude-session-manager/dist/pty-census.d.ts.map +1 -1
  164. package/payload/platform/services/claude-session-manager/dist/pty-census.js +214 -29
  165. package/payload/platform/services/claude-session-manager/dist/pty-census.js.map +1 -1
  166. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +0 -1
  167. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  168. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +36 -24
  169. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  170. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
  171. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +13 -1
  172. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
  173. package/payload/platform/services/claude-session-manager/dist/session-memory-cap.d.ts.map +1 -1
  174. package/payload/platform/services/claude-session-manager/dist/session-memory-cap.js +4 -2
  175. package/payload/platform/services/claude-session-manager/dist/session-memory-cap.js.map +1 -1
  176. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.d.ts +150 -0
  177. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.d.ts.map +1 -0
  178. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.js +333 -0
  179. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.js.map +1 -0
  180. package/payload/platform/services/claude-session-manager/dist/start-counter.d.ts +10 -0
  181. package/payload/platform/services/claude-session-manager/dist/start-counter.d.ts.map +1 -0
  182. package/payload/platform/services/claude-session-manager/dist/start-counter.js +55 -0
  183. package/payload/platform/services/claude-session-manager/dist/start-counter.js.map +1 -0
  184. package/payload/server/{chunk-Q6W4U6HL.js → chunk-JXWFVE5X.js} +83 -39
  185. package/payload/server/server.js +268 -73
  186. package/payload/server/{src-3I2RYZFB.js → src-4F37OHLK.js} +11 -1
  187. package/payload/platform/plugins/outlook/mcp/dist/__tests__/pkce-flow.test.d.ts +0 -2
  188. package/payload/platform/plugins/outlook/mcp/dist/__tests__/pkce-flow.test.js +0 -215
  189. package/payload/platform/plugins/outlook/mcp/dist/__tests__/pkce-flow.test.js.map +0 -1
@@ -0,0 +1,220 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+ import { processAuth } from '../../skills/data-portal/template/functions/api/auth'
3
+ import {
4
+ generatePasscode,
5
+ hashPasscode,
6
+ } from '../../skills/data-portal/template/functions/api/_lib/passcode.mjs'
7
+ import type { PortalEnv } from '../../skills/data-portal/template/functions/api/_lib/types'
8
+
9
+ const SALT = '000102030405060708090a0b0c0d0e0f'
10
+ // Real passcodes, because hashPasscode now refuses anything below the pinned
11
+ // strength — 'right' and 'wrong' would throw before they ever reached a route.
12
+ const RIGHT = generatePasscode()
13
+ const WRONG = generatePasscode()
14
+ const IP = '203.0.113.7'
15
+
16
+ async function envWith(people: Array<{ ownerId: string; passcode: string }>, attemptCount = 0) {
17
+ const rows = await Promise.all(
18
+ people.map(async (p) => ({
19
+ ownerId: p.ownerId,
20
+ salt: SALT,
21
+ hash: await hashPasscode(p.passcode, SALT),
22
+ })),
23
+ )
24
+ const sessions: unknown[][] = []
25
+ const cleared: unknown[][] = []
26
+ const scopes: unknown[] = []
27
+ const env = {
28
+ DB: {
29
+ prepare(query: string) {
30
+ let bound: unknown[] = []
31
+ const stmt = {
32
+ bind(...v: unknown[]) {
33
+ bound = v
34
+ if (/auth_attempts/i.test(query)) scopes.push(v[0])
35
+ return stmt
36
+ },
37
+ async run() {
38
+ if (/INSERT INTO sessions/i.test(query)) sessions.push(bound)
39
+ if (/DELETE FROM auth_attempts/i.test(query)) cleared.push(bound)
40
+ return { meta: { changes: 1 } }
41
+ },
42
+ async all() {
43
+ return { results: [] }
44
+ },
45
+ async first<T>() {
46
+ if (/FROM auth_attempts/i.test(query)) return { count: attemptCount + 1 } as T
47
+ if (/FROM people/i.test(query)) {
48
+ return (rows.find((r) => r.ownerId === bound[0]) ?? null) as T | null
49
+ }
50
+ return null as T | null
51
+ },
52
+ }
53
+ return stmt
54
+ },
55
+ },
56
+ BUCKET: {} as never,
57
+ } as unknown as PortalEnv
58
+ return { env, sessions, cleared, scopes }
59
+ }
60
+
61
+ afterEach(() => vi.restoreAllMocks())
62
+
63
+ describe('processAuth', () => {
64
+ it('accepts the right passcode and mints a session', async () => {
65
+ const { env, sessions } = await envWith([{ ownerId: 'alice', passcode: RIGHT }])
66
+ const lines: string[] = []
67
+ const res = await processAuth(
68
+ { ownerId: 'alice', passcode: RIGHT },
69
+ env,
70
+ (l) => lines.push(l),
71
+ () => 'sess-1',
72
+ 1_000_000,
73
+ IP,
74
+ )
75
+ expect(res.status).toBe(200)
76
+ expect(res.sessionId).toBe('sess-1')
77
+ expect(sessions.length).toBe(1)
78
+ expect(lines.join('\n')).toContain('op=auth owner="alice" result=ok')
79
+ })
80
+
81
+ it('denies the wrong passcode and mints no session', async () => {
82
+ const { env, sessions } = await envWith([{ ownerId: 'alice', passcode: RIGHT }])
83
+ const lines: string[] = []
84
+ const res = await processAuth(
85
+ { ownerId: 'alice', passcode: WRONG },
86
+ env,
87
+ (l) => lines.push(l),
88
+ () => 'sess-1',
89
+ 1_000_000,
90
+ IP,
91
+ )
92
+ expect(res.status).toBe(401)
93
+ expect(sessions.length).toBe(0)
94
+ expect(lines.join('\n')).toContain('result=denied')
95
+ })
96
+
97
+ it('denies an unknown owner without revealing that it is unknown', async () => {
98
+ const { env } = await envWith([{ ownerId: 'alice', passcode: RIGHT }])
99
+ const res = await processAuth(
100
+ { ownerId: 'nobody', passcode: RIGHT },
101
+ env,
102
+ () => {},
103
+ () => 'sess-1',
104
+ 1_000_000,
105
+ IP,
106
+ )
107
+ expect(res.status).toBe(401)
108
+ // Same payload as a wrong passcode: an enumeration oracle would otherwise
109
+ // tell an attacker which people exist.
110
+ expect(res.payload).toEqual({ ok: false, error: 'denied' })
111
+ })
112
+
113
+ it('rate-limits before comparing the passcode', async () => {
114
+ const { env, sessions } = await envWith([{ ownerId: 'alice', passcode: RIGHT }], 5)
115
+ const lines: string[] = []
116
+ const res = await processAuth(
117
+ { ownerId: 'alice', passcode: RIGHT },
118
+ env,
119
+ (l) => lines.push(l),
120
+ () => 'sess-1',
121
+ 1_000_000,
122
+ IP,
123
+ )
124
+ expect(res.status).toBe(429)
125
+ expect(sessions.length).toBe(0)
126
+ expect(lines.join('\n')).toContain('result=rate-limited')
127
+ })
128
+
129
+ it('never logs the passcode', async () => {
130
+ const { env } = await envWith([{ ownerId: 'alice', passcode: RIGHT }])
131
+ const lines: string[] = []
132
+ await processAuth(
133
+ { ownerId: 'alice', passcode: RIGHT },
134
+ env,
135
+ (l) => lines.push(l),
136
+ () => 'sess-1',
137
+ 1_000_000,
138
+ IP,
139
+ )
140
+ expect(lines.join('\n')).not.toContain(RIGHT)
141
+ })
142
+
143
+ it('rejects a malformed body', async () => {
144
+ const { env } = await envWith([{ ownerId: 'alice', passcode: RIGHT }])
145
+ const res = await processAuth({} as never, env, () => {}, () => 'sess-1', 1_000_000, IP)
146
+ expect(res.status).toBe(400)
147
+ })
148
+
149
+ it('hashes for an unknown owner too, closing the timing oracle', async () => {
150
+ // The identical DENIED payload is cosmetic on its own: an unknown owner
151
+ // returning after one D1 read while a known one also hashes enumerates the
152
+ // enrolled people outright. Both paths must pay.
153
+ //
154
+ // 1689 closed a ~5ms-vs-250ms gap here. 1708 replaced PBKDF2 with one
155
+ // SHA-256, shrinking the gap to a measured 43.8us — still inside the band
156
+ // remote timing attacks have been shown to resolve, so the dummy stays. A
157
+ // call count is the right instrument: it is deterministic where a clock is
158
+ // not, and it fails if someone drops the dummy without restoring the cost.
159
+ // Both envs are built BEFORE the spy: envWith hashes its own fixtures, and
160
+ // counting that setup work would let the two totals match without the route
161
+ // hashing at all.
162
+ const known = await envWith([{ ownerId: 'alice', passcode: RIGHT }])
163
+ const unknown = await envWith([{ ownerId: 'alice', passcode: RIGHT }])
164
+ const spy = vi.spyOn(crypto.subtle, 'digest')
165
+
166
+ await processAuth({ ownerId: 'alice', passcode: WRONG }, known.env, () => {}, () => 's', 1_000_000, IP)
167
+ const hashesForKnown = spy.mock.calls.length
168
+ spy.mockClear()
169
+
170
+ await processAuth({ ownerId: 'nobody', passcode: WRONG }, unknown.env, () => {}, () => 's', 1_000_000, IP)
171
+ const hashesForUnknown = spy.mock.calls.length
172
+
173
+ expect(hashesForKnown).toBeGreaterThan(0)
174
+ expect(hashesForUnknown).toBe(hashesForKnown)
175
+ })
176
+
177
+ it('scopes the rate limiter on owner AND ip, so one caller cannot lock out another', async () => {
178
+ // Keyed on the owner alone the counter is a weapon: six unauthenticated
179
+ // requests naming a person lock that person out of their own portal.
180
+ const { env, scopes } = await envWith([{ ownerId: 'alice', passcode: RIGHT }])
181
+ await processAuth({ ownerId: 'alice', passcode: RIGHT }, env, () => {}, () => 's', 1_000_000, IP)
182
+ expect(scopes[0]).toBe(`alice|${IP}`)
183
+ expect(String(scopes[0])).toContain(IP)
184
+ })
185
+
186
+ it('clears the counter on a successful sign-in, so a person cannot self-lock', async () => {
187
+ const { env, cleared } = await envWith([{ ownerId: 'alice', passcode: RIGHT }])
188
+ await processAuth({ ownerId: 'alice', passcode: RIGHT }, env, () => {}, () => 's', 1_000_000, IP)
189
+ expect(cleared.length).toBe(1)
190
+ expect(cleared[0][0]).toBe(`alice|${IP}`)
191
+ })
192
+
193
+ it('does not clear the counter on a failed sign-in', async () => {
194
+ const { env, cleared } = await envWith([{ ownerId: 'alice', passcode: RIGHT }])
195
+ await processAuth({ ownerId: 'alice', passcode: WRONG }, env, () => {}, () => 's', 1_000_000, IP)
196
+ expect(cleared.length).toBe(0)
197
+ })
198
+
199
+ it('binds the session to the passcode, so rotation revokes it', async () => {
200
+ // sessions carries pcCheck; resolveSession joins people and requires it to
201
+ // still match. Rotation changes the hash, so old sessions die by
202
+ // construction rather than by an operator remembering a cleanup step.
203
+ const { env, sessions } = await envWith([{ ownerId: 'alice', passcode: RIGHT }])
204
+ await processAuth({ ownerId: 'alice', passcode: RIGHT }, env, () => {}, () => 's', 1_000_000, IP)
205
+ const expected = (await hashPasscode(RIGHT, SALT)).slice(0, 16)
206
+ // bind order: sessionId, ownerId, pcCheck, expiresAt, createdAt
207
+ expect(sessions[0][2]).toBe(expected)
208
+ })
209
+
210
+ it('quotes the owner in log lines, so a crafted ownerId cannot forge one', async () => {
211
+ const { env } = await envWith([{ ownerId: 'alice', passcode: RIGHT }])
212
+ const lines: string[] = []
213
+ const forged = 'x result=ok\n[data-portal] op=auth owner=alice result=ok'
214
+ await processAuth({ ownerId: forged, passcode: WRONG }, env, (l) => lines.push(l), () => 's', 1_000_000, IP)
215
+ // Exactly one line, and the injected newline must not have started another.
216
+ expect(lines.length).toBe(1)
217
+ expect(lines[0].split('\n').length).toBe(1)
218
+ expect(lines[0]).toMatch(/result=denied/)
219
+ })
220
+ })
@@ -0,0 +1,201 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ SESSION_COOKIE,
4
+ SESSION_TTL_MS,
5
+ mintSession,
6
+ resolveSession,
7
+ readSessionCookie,
8
+ } from '../../skills/data-portal/template/functions/api/_lib/session'
9
+ import {
10
+ ownerPrefix,
11
+ authorizeKey,
12
+ } from '../../skills/data-portal/template/functions/api/_lib/authorize'
13
+ import type { D1Database } from '../../skills/data-portal/template/functions/api/_lib/types'
14
+
15
+ // Models the real resolveSession query: sessions JOIN people, requiring the
16
+ // session's pcCheck to still match substr(people.hash, 1, 16). A fake that
17
+ // skipped the join would let the revocation tests below pass vacuously.
18
+ function fakeDb(
19
+ seed: Array<{ sessionId: string; ownerId: string; pcCheck: string; expiresAt: number }> = [],
20
+ people: Array<{ ownerId: string; hash: string }> = [{ ownerId: 'alice', hash: 'HASH-v1-padded00' }],
21
+ ) {
22
+ const rows = [...seed]
23
+ const peopleRows = [...people]
24
+ const db: D1Database = {
25
+ prepare(query: string) {
26
+ let bound: unknown[] = []
27
+ const stmt = {
28
+ bind(...v: unknown[]) {
29
+ bound = v
30
+ return stmt
31
+ },
32
+ async run() {
33
+ if (/INSERT INTO sessions/i.test(query)) {
34
+ const [sessionId, ownerId, pcCheck, expiresAt] = bound as [string, string, string, number]
35
+ rows.push({ sessionId, ownerId, pcCheck, expiresAt })
36
+ }
37
+ if (/DELETE FROM sessions/i.test(query)) {
38
+ const [ownerId, nowMs] = bound as [string, number]
39
+ for (let i = rows.length - 1; i >= 0; i--) {
40
+ if (rows[i].ownerId === ownerId && rows[i].expiresAt <= nowMs) rows.splice(i, 1)
41
+ }
42
+ }
43
+ return { meta: { changes: 1 } }
44
+ },
45
+ async all() {
46
+ return { results: [] }
47
+ },
48
+ async first<T>() {
49
+ const [sessionId, nowMs] = bound as [string, number]
50
+ const hit = rows.find((r) => r.sessionId === sessionId && r.expiresAt > nowMs)
51
+ if (!hit) return null as T | null
52
+ const person = peopleRows.find((p) => p.ownerId === hit.ownerId)
53
+ if (!person) return null as T | null // unenrolled -> the JOIN drops it
54
+ if (person.hash.slice(0, 16) !== hit.pcCheck) return null as T | null // rotated
55
+ return { ownerId: hit.ownerId } as T
56
+ },
57
+ }
58
+ return stmt
59
+ },
60
+ }
61
+ return { db, rows, peopleRows }
62
+ }
63
+
64
+ const PC = 'HASH-v1-padded00'.slice(0, 16)
65
+
66
+ describe('sessions', () => {
67
+ it('mints a session resolvable to its owner', async () => {
68
+ const { db } = fakeDb()
69
+ await mintSession(db, 'alice', 'sess-1', 1_000_000, PC)
70
+ expect(await resolveSession(db, 'sess-1', 1_000_000)).toEqual({ ownerId: 'alice' })
71
+ })
72
+
73
+ it('does not resolve an expired session', async () => {
74
+ const { db } = fakeDb()
75
+ await mintSession(db, 'alice', 'sess-1', 1_000_000, PC)
76
+ expect(await resolveSession(db, 'sess-1', 1_000_000 + SESSION_TTL_MS + 1)).toBeNull()
77
+ })
78
+
79
+ it('does not resolve an unknown session', async () => {
80
+ const { db } = fakeDb()
81
+ expect(await resolveSession(db, 'nope', 1_000_000)).toBeNull()
82
+ })
83
+
84
+ it('prunes that owner expired rows on mint, so sessions do not grow forever', async () => {
85
+ const { db, rows } = fakeDb([
86
+ { sessionId: 'old', ownerId: 'alice', pcCheck: PC, expiresAt: 1 },
87
+ ])
88
+ await mintSession(db, 'alice', 'sess-1', 1_000_000, PC)
89
+ expect(rows.map((r) => r.sessionId)).toEqual(['sess-1'])
90
+ })
91
+ })
92
+
93
+ describe('session revocation — the property the design claims', () => {
94
+ it('stops resolving once the passcode is rotated', async () => {
95
+ // The whole reason a leaked passcode has a remedy. Rotation changes
96
+ // people.hash, so pcCheck no longer matches and the live session dies —
97
+ // by construction, not by remembering to delete anything.
98
+ const { db, peopleRows } = fakeDb()
99
+ await mintSession(db, 'alice', 'sess-1', 1_000_000, PC)
100
+ expect(await resolveSession(db, 'sess-1', 1_000_000)).toEqual({ ownerId: 'alice' })
101
+
102
+ peopleRows[0].hash = 'HASH-v2-rotated0' // operator re-runs enrolment
103
+
104
+ expect(await resolveSession(db, 'sess-1', 1_000_000)).toBeNull()
105
+ })
106
+
107
+ it('stops resolving once the person is unenrolled', async () => {
108
+ const { db, peopleRows } = fakeDb()
109
+ await mintSession(db, 'alice', 'sess-1', 1_000_000, PC)
110
+ peopleRows.length = 0
111
+ expect(await resolveSession(db, 'sess-1', 1_000_000)).toBeNull()
112
+ })
113
+ })
114
+
115
+ describe('readSessionCookie', () => {
116
+ it('reads the portal session cookie', () => {
117
+ expect(readSessionCookie('__Host-__portal_session=abc123')).toBe('abc123')
118
+ })
119
+ it('reads it from among other cookies', () => {
120
+ expect(readSessionCookie('foo=1; __Host-__portal_session=abc123; bar=2')).toBe('abc123')
121
+ })
122
+ it('is null when absent', () => {
123
+ expect(readSessionCookie('foo=1')).toBeNull()
124
+ })
125
+ it('is null when the header is absent', () => {
126
+ expect(readSessionCookie(null)).toBeNull()
127
+ })
128
+ it('does not match a lookalike cookie name', () => {
129
+ expect(readSessionCookie('x__Host-__portal_session=abc123')).toBeNull()
130
+ })
131
+ it('uses the __Host- prefix, which forbids a Domain attribute', () => {
132
+ // Without the prefix, a cookie set on a sibling subdomain with
133
+ // Domain=.example.com reaches this host and can shadow the real session,
134
+ // so a victim uploads into the attacker's prefix. The prefix is the only
135
+ // thing browsers enforce that against, and we already meet its
136
+ // preconditions (Secure, Path=/, no Domain).
137
+ expect(SESSION_COOKIE.startsWith('__Host-')).toBe(true)
138
+ })
139
+ it('keeps scanning past a valueless duplicate rather than returning null', () => {
140
+ // An attacker-planted empty cookie sorted first must not blank the real
141
+ // session — that is a persistent logout denial-of-service.
142
+ expect(readSessionCookie('__Host-__portal_session=; __Host-__portal_session=real')).toBe('real')
143
+ })
144
+ })
145
+
146
+ describe('authorizeKey — the isolation rule', () => {
147
+ it('allows a key under the owner prefix', () => {
148
+ expect(authorizeKey('alice', 'alice/invoice.pdf')).toBe(true)
149
+ })
150
+
151
+ it('denies another owner prefix', () => {
152
+ expect(authorizeKey('alice', 'bob/invoice.pdf')).toBe(false)
153
+ })
154
+
155
+ it('denies a traversal escape', () => {
156
+ expect(authorizeKey('alice', 'alice/../bob/invoice.pdf')).toBe(false)
157
+ })
158
+
159
+ it('denies a prefix that merely starts with the owner id', () => {
160
+ // 'alice' must not authorize 'alice-evil/...': a startsWith check on the
161
+ // bare id would pass this. The separator is part of the rule.
162
+ expect(authorizeKey('alice', 'alice-evil/invoice.pdf')).toBe(false)
163
+ })
164
+
165
+ it('denies a bare key with no prefix', () => {
166
+ expect(authorizeKey('alice', 'invoice.pdf')).toBe(false)
167
+ })
168
+
169
+ it('denies an absolute-looking key', () => {
170
+ expect(authorizeKey('alice', '/alice/invoice.pdf')).toBe(false)
171
+ })
172
+
173
+ it('denies an empty key', () => {
174
+ expect(authorizeKey('alice', '')).toBe(false)
175
+ })
176
+
177
+ // authorizeKey's correctness silently depends on ownerId containing no
178
+ // separator. If two people were ever enrolled as 'alice' and 'alice/2024',
179
+ // then 'alice/2024/x.pdf' starts with 'alice/' and alice reads the other
180
+ // person's files. Enrolment is an agent writing rows, so the invariant
181
+ // cannot rest on the skill's prose — enforce it at the gate.
182
+ it('denies when the ownerId itself contains a separator', () => {
183
+ expect(authorizeKey('alice/2024', 'alice/2024/invoice.pdf')).toBe(false)
184
+ })
185
+
186
+ it('denies an ownerId outside [a-z0-9-], failing closed', () => {
187
+ for (const bad of ['alice/2024', 'Alice', 'alice..', 'alice bob', '../alice', 'alice%2f']) {
188
+ expect(authorizeKey(bad, `${bad}/invoice.pdf`)).toBe(false)
189
+ }
190
+ })
191
+
192
+ it('still allows a well-formed ownerId', () => {
193
+ expect(authorizeKey('alice-smith-2', 'alice-smith-2/invoice.pdf')).toBe(true)
194
+ })
195
+ })
196
+
197
+ describe('ownerPrefix', () => {
198
+ it('is the owner id plus a separator', () => {
199
+ expect(ownerPrefix('alice')).toBe('alice/')
200
+ })
201
+ })
@@ -0,0 +1,178 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { processFiles } from '../../skills/data-portal/template/functions/api/files'
3
+ import { processDownload } from '../../skills/data-portal/template/functions/api/download'
4
+ import { processDelete } from '../../skills/data-portal/template/functions/api/delete'
5
+ import type { PortalEnv } from '../../skills/data-portal/template/functions/api/_lib/types'
6
+
7
+ // alice's session is 'sess-a'; bob owns 'bob/secret.pdf'.
8
+ function makeEnv() {
9
+ const manifest = [
10
+ { fileId: 'f1', ownerId: 'alice', filename: 'a.pdf', objectKey: 'alice/a.pdf', size: 10 },
11
+ { fileId: 'f2', ownerId: 'bob', filename: 'secret.pdf', objectKey: 'bob/secret.pdf', size: 20 },
12
+ ]
13
+ const objects = new Map<string, string>([
14
+ ['alice/a.pdf', 'A'],
15
+ ['bob/secret.pdf', 'B'],
16
+ ])
17
+ const deleted: string[] = []
18
+ const env = {
19
+ DB: {
20
+ prepare(query: string) {
21
+ let bound: unknown[] = []
22
+ const stmt = {
23
+ bind(...v: unknown[]) {
24
+ bound = v
25
+ return stmt
26
+ },
27
+ async run() {
28
+ if (/DELETE FROM manifest/i.test(query)) {
29
+ const i = manifest.findIndex((m) => m.objectKey === bound[0])
30
+ if (i >= 0) manifest.splice(i, 1)
31
+ }
32
+ return { meta: { changes: 1 } }
33
+ },
34
+ async all<T>() {
35
+ const owner = bound[0] as string
36
+ return { results: manifest.filter((m) => m.ownerId === owner) as T[] }
37
+ },
38
+ async first<T>() {
39
+ if (/FROM sessions/i.test(query)) {
40
+ const map: Record<string, string> = { 'sess-a': 'alice', 'sess-b': 'bob' }
41
+ const owner = map[bound[0] as string]
42
+ return (owner ? { ownerId: owner } : null) as T | null
43
+ }
44
+ return (manifest.find((m) => m.objectKey === bound[0]) ?? null) as T | null
45
+ },
46
+ }
47
+ return stmt
48
+ },
49
+ },
50
+ BUCKET: {
51
+ async get(key: string) {
52
+ const v = objects.get(key)
53
+ return v ? { body: null, size: v.length } : null
54
+ },
55
+ async delete(key: string) {
56
+ deleted.push(key)
57
+ objects.delete(key)
58
+ },
59
+ async put() {
60
+ return {}
61
+ },
62
+ async head(key: string) {
63
+ const v = objects.get(key)
64
+ return v ? { size: v.length } : null
65
+ },
66
+ },
67
+ } as unknown as PortalEnv
68
+ return { env, deleted, manifest, objects }
69
+ }
70
+
71
+ describe('processFiles', () => {
72
+ it("lists only the session owner's files", async () => {
73
+ const { env } = makeEnv()
74
+ const res = await processFiles('sess-a', env, () => {}, 1_000_000)
75
+ expect(res.status).toBe(200)
76
+ const files = res.payload.files as Array<{ objectKey: string }>
77
+ expect(files.map((f) => f.objectKey)).toEqual(['alice/a.pdf'])
78
+ })
79
+
80
+ it("never includes another owner's file", async () => {
81
+ const { env } = makeEnv()
82
+ const res = await processFiles('sess-a', env, () => {}, 1_000_000)
83
+ expect(JSON.stringify(res.payload)).not.toContain('bob')
84
+ expect(JSON.stringify(res.payload)).not.toContain('secret.pdf')
85
+ })
86
+
87
+ it('denies an unauthenticated caller', async () => {
88
+ const { env } = makeEnv()
89
+ expect((await processFiles('', env, () => {}, 1_000_000)).status).toBe(401)
90
+ })
91
+ })
92
+
93
+ describe('processDownload — isolation', () => {
94
+ it("allows the owner's own key", async () => {
95
+ const { env } = makeEnv()
96
+ expect((await processDownload('sess-a', 'alice/a.pdf', env, () => {}, 1_000_000)).status).toBe(
97
+ 200,
98
+ )
99
+ })
100
+
101
+ it("denies person A's session on person B's key", async () => {
102
+ const { env } = makeEnv()
103
+ const res = await processDownload('sess-a', 'bob/secret.pdf', env, () => {}, 1_000_000)
104
+ expect(res.status).toBe(403)
105
+ })
106
+
107
+ it('denies a traversal escape', async () => {
108
+ const { env } = makeEnv()
109
+ const res = await processDownload('sess-a', 'alice/../bob/secret.pdf', env, () => {}, 1_000_000)
110
+ expect(res.status).toBe(403)
111
+ })
112
+
113
+ it('denies an unauthenticated caller', async () => {
114
+ const { env } = makeEnv()
115
+ expect((await processDownload('', 'alice/a.pdf', env, () => {}, 1_000_000)).status).toBe(401)
116
+ })
117
+ })
118
+
119
+ describe('processDelete — isolation and both-sides', () => {
120
+ it('removes object and manifest row together', async () => {
121
+ const { env, deleted, manifest } = makeEnv()
122
+ const res = await processDelete('sess-a', 'alice/a.pdf', env, () => {}, () => 1_000_000)
123
+ expect(res.status).toBe(200)
124
+ expect(deleted).toEqual(['alice/a.pdf'])
125
+ expect(manifest.find((m) => m.objectKey === 'alice/a.pdf')).toBeUndefined()
126
+ })
127
+
128
+ it("denies person A's session on person B's key, and deletes nothing", async () => {
129
+ const { env, deleted, manifest } = makeEnv()
130
+ const res = await processDelete('sess-a', 'bob/secret.pdf', env, () => {}, () => 1_000_000)
131
+ expect(res.status).toBe(403)
132
+ expect(deleted).toEqual([])
133
+ expect(manifest.find((m) => m.objectKey === 'bob/secret.pdf')).toBeDefined()
134
+ })
135
+
136
+ it('denies an unauthenticated caller', async () => {
137
+ const { env, deleted } = makeEnv()
138
+ expect((await processDelete('', 'alice/a.pdf', env, () => {}, () => 1_000_000)).status).toBe(401)
139
+ expect(deleted).toEqual([])
140
+ })
141
+
142
+ it('emits a per-step lifeline, not one bare line', async () => {
143
+ const { env } = makeEnv()
144
+ const lines: string[] = []
145
+ await processDelete('sess-a', 'alice/a.pdf', env, (l) => lines.push(l), () => 1_000_000)
146
+ const joined = lines.join('\n')
147
+ expect(joined).toContain('op=r2-delete')
148
+ expect(joined).toContain('op=manifest-delete')
149
+ expect(joined).toMatch(/op=complete .*result=ok objectPresent=false rowPresent=false/)
150
+ })
151
+
152
+ it('reports the R2 failure instead of throwing out of the handler', async () => {
153
+ // Unwrapped, a throw here escapes to a bare Pages 500 with zero
154
+ // [data-portal] lines — the lifeline goes silent exactly when it is needed.
155
+ const { env, manifest } = makeEnv()
156
+ ;(env.BUCKET as unknown as { delete: () => Promise<void> }).delete = () => {
157
+ throw new Error('r2 unavailable')
158
+ }
159
+ const lines: string[] = []
160
+ const res = await processDelete('sess-a', 'alice/a.pdf', env, (l) => lines.push(l), () => 1_000_000)
161
+ expect(res.status).toBe(502)
162
+ expect(lines.join('\n')).toContain('op=r2-delete')
163
+ expect(lines.join('\n')).toContain('result=failed')
164
+ // The row must survive an object that was never deleted.
165
+ expect(manifest.find((m) => m.objectKey === 'alice/a.pdf')).toBeDefined()
166
+ })
167
+
168
+ it('derives result from the post-condition rather than hardcoding ok', async () => {
169
+ // A delete that left the object behind must not print result=ok next to
170
+ // objectPresent=true — a self-contradicting line.
171
+ const { env } = makeEnv()
172
+ ;(env.BUCKET as unknown as { delete: () => Promise<void> }).delete = async () => {}
173
+ const lines: string[] = []
174
+ const res = await processDelete('sess-a', 'alice/a.pdf', env, (l) => lines.push(l), () => 1_000_000)
175
+ expect(res.status).toBe(500)
176
+ expect(lines.join('\n')).toMatch(/op=complete .*result=incomplete objectPresent=true/)
177
+ })
178
+ })
@@ -0,0 +1,32 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { processBooking } from '../../skills/calendar-site/template/functions/api/book'
3
+
4
+ // The harness must reach code inside a skill's template/ directory, which is
5
+ // part of no tsc project. This pins that reach; if it breaks, every template
6
+ // test below it is unreachable.
7
+ describe('harness reaches skill template functions', () => {
8
+ it('imports and runs a template Pages Function against an injected fake D1', async () => {
9
+ const lines: string[] = []
10
+ const fakeDb = {
11
+ prepare: () => ({
12
+ bind() {
13
+ return this
14
+ },
15
+ run: async () => ({ meta: { changes: 1 } }),
16
+ }),
17
+ }
18
+ const res = await processBooking(
19
+ {
20
+ slotStart: '2026-01-01T09:00:00Z',
21
+ slotEnd: '2026-01-01T09:30:00Z',
22
+ name: 'harness',
23
+ email: 'h@e.com',
24
+ } as never,
25
+ { DB: fakeDb } as never,
26
+ (l) => lines.push(l),
27
+ () => 'fixed-id',
28
+ )
29
+ expect(res.status).toBe(200)
30
+ expect(lines.join('\n')).toContain('[calendar-booking]')
31
+ })
32
+ })