@rubytech/create-sitedesk-code 0.1.526 → 0.1.527

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 (190) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +73 -7
  3. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +7 -0
  4. package/payload/platform/plugins/docs/references/admin-ui.md +70 -4
  5. package/payload/platform/plugins/docs/references/internals.md +1 -1
  6. package/payload/platform/plugins/docs/references/platform.md +1 -1
  7. package/payload/platform/plugins/linkedin-extension/extension/README.md +10 -2
  8. package/payload/platform/plugins/linkedin-extension/extension/background/sw.js +37 -4
  9. package/payload/platform/plugins/linkedin-extension/extension/content/profile.js +17 -2
  10. package/payload/platform/plugins/linkedin-extension/extension/content/thread.js +17 -2
  11. package/payload/platform/plugins/linkedin-extension/extension/manifest.json +1 -0
  12. package/payload/platform/plugins/linkedin-extension/extension/options/options.html +1 -0
  13. package/payload/platform/plugins/linkedin-extension/extension/options/options.js +78 -3
  14. package/payload/server/{chunk-WT2PFGY4.js → chunk-KY3BBT67.js} +216 -1
  15. package/payload/server/maxy-edge.js +226 -6
  16. package/payload/server/public/activity.html +5 -5
  17. package/payload/server/public/agents.html +4 -4
  18. package/payload/server/public/assets/{AdminLoginScreens-BgDjueVe.js → AdminLoginScreens-DSt9DYZZ.js} +1 -1
  19. package/payload/server/public/assets/AdminLoginScreens-DSt9DYZZ.js.br +0 -0
  20. package/payload/server/public/assets/AdminLoginScreens-DSt9DYZZ.js.gz +0 -0
  21. package/payload/server/public/assets/{AdminShell-YctTrR-D.js → AdminShell-MM3_yp3N.js} +1 -1
  22. package/payload/server/public/assets/AdminShell-MM3_yp3N.js.br +0 -0
  23. package/payload/server/public/assets/AdminShell-MM3_yp3N.js.gz +0 -0
  24. package/payload/server/public/assets/{activity-DGQt5ys_.js → activity-aVvbaVd4.js} +1 -1
  25. package/payload/server/public/assets/activity-aVvbaVd4.js.br +0 -0
  26. package/payload/server/public/assets/activity-aVvbaVd4.js.gz +0 -0
  27. package/payload/server/public/assets/{admin-DnUxbB3P.js → admin-B0lQnkH-.js} +1 -1
  28. package/payload/server/public/assets/admin-B0lQnkH-.js.br +0 -0
  29. package/payload/server/public/assets/admin-B0lQnkH-.js.gz +0 -0
  30. package/payload/server/public/assets/{agents-Bc2LZjwC.js → agents-CrpVTPRG.js} +1 -1
  31. package/payload/server/public/assets/agents-CrpVTPRG.js.br +0 -0
  32. package/payload/server/public/assets/agents-CrpVTPRG.js.gz +0 -0
  33. package/payload/server/public/assets/browser-CYHUzTop.js +1 -0
  34. package/payload/server/public/assets/browser-CYHUzTop.js.br +0 -0
  35. package/payload/server/public/assets/browser-CYHUzTop.js.gz +0 -0
  36. package/payload/server/public/assets/{calendar-D25EhXbD.js → calendar-4IWQpwYv.js} +1 -1
  37. package/payload/server/public/assets/calendar-4IWQpwYv.js.br +0 -0
  38. package/payload/server/public/assets/calendar-4IWQpwYv.js.gz +0 -0
  39. package/payload/server/public/assets/chat-DmjZTqnV.js +1 -0
  40. package/payload/server/public/assets/chat-DmjZTqnV.js.br +0 -0
  41. package/payload/server/public/assets/chat-DmjZTqnV.js.gz +0 -0
  42. package/payload/server/public/assets/chevron-left-CIl-s3LZ.js +1 -0
  43. package/payload/server/public/assets/chevron-left-CIl-s3LZ.js.br +0 -0
  44. package/payload/server/public/assets/chevron-right-DpAi2BsF.js +1 -0
  45. package/payload/server/public/assets/chevron-right-DpAi2BsF.js.br +0 -0
  46. package/payload/server/public/assets/clock-CGuM9VRx.js +1 -0
  47. package/payload/server/public/assets/clock-CGuM9VRx.js.br +0 -0
  48. package/payload/server/public/assets/data-BpdFiZKH.js +1 -0
  49. package/payload/server/public/assets/data-BpdFiZKH.js.br +2 -0
  50. package/payload/server/public/assets/data-BpdFiZKH.js.gz +0 -0
  51. package/payload/server/public/assets/{file-download-CiGklOfA.js → file-download-DayBHhKS.js} +1 -1
  52. package/payload/server/public/assets/file-download-DayBHhKS.js.br +0 -0
  53. package/payload/server/public/assets/file-download-DayBHhKS.js.gz +0 -0
  54. package/payload/server/public/assets/{file-text-XjJPULAZ.js → file-text-BSyhVH0m.js} +1 -1
  55. package/payload/server/public/assets/file-text-BSyhVH0m.js.br +0 -0
  56. package/payload/server/public/assets/file-text-BSyhVH0m.js.gz +0 -0
  57. package/payload/server/public/assets/{graph-BjHJ0z_6.js → graph-Bjv3Tc3B.js} +1 -1
  58. package/payload/server/public/assets/graph-Bjv3Tc3B.js.br +0 -0
  59. package/payload/server/public/assets/graph-Bjv3Tc3B.js.gz +0 -0
  60. package/payload/server/public/assets/{graph-labels-D-0u37KA.js → graph-labels-DegLKGdx.js} +1 -1
  61. package/payload/server/public/assets/graph-labels-DegLKGdx.js.br +0 -0
  62. package/payload/server/public/assets/graph-labels-DegLKGdx.js.gz +0 -0
  63. package/payload/server/public/assets/{maximize-2-BJ9S-nfS.js → maximize-2-DJFG2EXJ.js} +1 -1
  64. package/payload/server/public/assets/maximize-2-DJFG2EXJ.js.br +0 -0
  65. package/payload/server/public/assets/maximize-2-DJFG2EXJ.js.gz +0 -0
  66. package/payload/server/public/assets/{operator-Cay4rOOK.js → operator-Du9Ve_PR.js} +1 -1
  67. package/payload/server/public/assets/operator-Du9Ve_PR.js.br +0 -0
  68. package/payload/server/public/assets/operator-Du9Ve_PR.js.gz +0 -0
  69. package/payload/server/public/assets/{page-shuh4ES9.js → page-DgHYxG3M.js} +1 -1
  70. package/payload/server/public/assets/page-DgHYxG3M.js.br +0 -0
  71. package/payload/server/public/assets/page-DgHYxG3M.js.gz +0 -0
  72. package/payload/server/public/assets/{page-Cci_j_y7.js → page-qV1ypb6i.js} +5 -5
  73. package/payload/server/public/assets/page-qV1ypb6i.js.br +0 -0
  74. package/payload/server/public/assets/page-qV1ypb6i.js.gz +0 -0
  75. package/payload/server/public/assets/{public-BbJcQGsO.js → public-CNVEM-Bx.js} +1 -1
  76. package/payload/server/public/assets/public-CNVEM-Bx.js.br +0 -0
  77. package/payload/server/public/assets/public-CNVEM-Bx.js.gz +0 -0
  78. package/payload/server/public/assets/{rotate-ccw-jeSVwUAv.js → rotate-ccw-DQCaky8_.js} +1 -1
  79. package/payload/server/public/assets/rotate-ccw-DQCaky8_.js.br +0 -0
  80. package/payload/server/public/assets/rotate-ccw-DQCaky8_.js.gz +0 -0
  81. package/payload/server/public/assets/{routines-D3M1Wh3a.js → routines-DwK5FeLm.js} +1 -1
  82. package/payload/server/public/assets/routines-DwK5FeLm.js.br +0 -0
  83. package/payload/server/public/assets/routines-DwK5FeLm.js.gz +0 -0
  84. package/payload/server/public/assets/{skills-Bhej2ub1.js → skills-CosN3uA4.js} +1 -1
  85. package/payload/server/public/assets/skills-CosN3uA4.js.br +0 -0
  86. package/payload/server/public/assets/skills-CosN3uA4.js.gz +0 -0
  87. package/payload/server/public/assets/tasks-f0QikHmw.js +3 -0
  88. package/payload/server/public/assets/tasks-f0QikHmw.js.br +0 -0
  89. package/payload/server/public/assets/tasks-f0QikHmw.js.gz +0 -0
  90. package/payload/server/public/assets/{time-entry-format-r5UY9rjG.js → time-entry-format-DLTdS35_.js} +1 -1
  91. package/payload/server/public/assets/time-entry-format-DLTdS35_.js.br +0 -0
  92. package/payload/server/public/assets/time-entry-format-DLTdS35_.js.gz +0 -0
  93. package/payload/server/public/assets/{triangle-alert--PCKaewD.js → triangle-alert-CpwrbdYq.js} +1 -1
  94. package/payload/server/public/assets/triangle-alert-CpwrbdYq.js.br +3 -0
  95. package/payload/server/public/assets/triangle-alert-CpwrbdYq.js.gz +0 -0
  96. package/payload/server/public/assets/{useCopyFeedback-Dg5lNgqH.js → useCopyFeedback-D01gSp36.js} +1 -1
  97. package/payload/server/public/assets/useCopyFeedback-D01gSp36.js.br +0 -0
  98. package/payload/server/public/assets/useCopyFeedback-D01gSp36.js.gz +0 -0
  99. package/payload/server/public/assets/useSubAccountSwitcher-CJzJ9xV3.css +1 -0
  100. package/payload/server/public/assets/useSubAccountSwitcher-CJzJ9xV3.css.br +0 -0
  101. package/payload/server/public/assets/useSubAccountSwitcher-CJzJ9xV3.css.gz +0 -0
  102. package/payload/server/public/assets/{useVoiceRecorder-BY6ZsGMH.js → useVoiceRecorder-DiPgyDUy.js} +1 -1
  103. package/payload/server/public/assets/useVoiceRecorder-DiPgyDUy.js.br +0 -0
  104. package/payload/server/public/assets/useVoiceRecorder-DiPgyDUy.js.gz +0 -0
  105. package/payload/server/public/assets/{wrench-Bw1FMolk.js → wrench-C5SJreEu.js} +1 -1
  106. package/payload/server/public/assets/wrench-C5SJreEu.js.br +1 -0
  107. package/payload/server/public/assets/wrench-C5SJreEu.js.gz +0 -0
  108. package/payload/server/public/brand-defaults.css +1 -1
  109. package/payload/server/public/browser.html +4 -4
  110. package/payload/server/public/calendar.html +7 -7
  111. package/payload/server/public/chat.html +13 -13
  112. package/payload/server/public/data.html +12 -12
  113. package/payload/server/public/graph.html +9 -9
  114. package/payload/server/public/index.html +15 -15
  115. package/payload/server/public/operator.html +14 -14
  116. package/payload/server/public/public.html +13 -13
  117. package/payload/server/public/routines.html +7 -7
  118. package/payload/server/public/skills.html +5 -5
  119. package/payload/server/public/tasks.html +6 -6
  120. package/payload/server/server.js +43 -5
  121. package/payload/server/public/assets/AdminLoginScreens-BgDjueVe.js.br +0 -0
  122. package/payload/server/public/assets/AdminLoginScreens-BgDjueVe.js.gz +0 -0
  123. package/payload/server/public/assets/AdminShell-YctTrR-D.js.br +0 -0
  124. package/payload/server/public/assets/AdminShell-YctTrR-D.js.gz +0 -0
  125. package/payload/server/public/assets/activity-DGQt5ys_.js.br +0 -0
  126. package/payload/server/public/assets/activity-DGQt5ys_.js.gz +0 -0
  127. package/payload/server/public/assets/admin-DnUxbB3P.js.br +0 -0
  128. package/payload/server/public/assets/admin-DnUxbB3P.js.gz +0 -0
  129. package/payload/server/public/assets/agents-Bc2LZjwC.js.br +0 -0
  130. package/payload/server/public/assets/agents-Bc2LZjwC.js.gz +0 -0
  131. package/payload/server/public/assets/browser-BtF7SzvG.js +0 -1
  132. package/payload/server/public/assets/browser-BtF7SzvG.js.br +0 -0
  133. package/payload/server/public/assets/browser-BtF7SzvG.js.gz +0 -0
  134. package/payload/server/public/assets/calendar-D25EhXbD.js.br +0 -0
  135. package/payload/server/public/assets/calendar-D25EhXbD.js.gz +0 -0
  136. package/payload/server/public/assets/chat-z4jsHFXg.js +0 -1
  137. package/payload/server/public/assets/chat-z4jsHFXg.js.br +0 -0
  138. package/payload/server/public/assets/chat-z4jsHFXg.js.gz +0 -0
  139. package/payload/server/public/assets/chevron-left-BD09SN6R.js +0 -1
  140. package/payload/server/public/assets/chevron-right-ChmWeo7T.js +0 -1
  141. package/payload/server/public/assets/chevron-right-ChmWeo7T.js.br +0 -0
  142. package/payload/server/public/assets/clock-Yqf7Ke7I.js +0 -1
  143. package/payload/server/public/assets/clock-Yqf7Ke7I.js.br +0 -0
  144. package/payload/server/public/assets/clock-Yqf7Ke7I.js.gz +0 -0
  145. package/payload/server/public/assets/data-RmnKj5nC.js +0 -1
  146. package/payload/server/public/assets/data-RmnKj5nC.js.br +0 -2
  147. package/payload/server/public/assets/data-RmnKj5nC.js.gz +0 -0
  148. package/payload/server/public/assets/file-download-CiGklOfA.js.br +0 -0
  149. package/payload/server/public/assets/file-download-CiGklOfA.js.gz +0 -0
  150. package/payload/server/public/assets/file-text-XjJPULAZ.js.br +0 -0
  151. package/payload/server/public/assets/file-text-XjJPULAZ.js.gz +0 -0
  152. package/payload/server/public/assets/graph-BjHJ0z_6.js.br +0 -0
  153. package/payload/server/public/assets/graph-BjHJ0z_6.js.gz +0 -0
  154. package/payload/server/public/assets/graph-labels-D-0u37KA.js.br +0 -0
  155. package/payload/server/public/assets/graph-labels-D-0u37KA.js.gz +0 -0
  156. package/payload/server/public/assets/maximize-2-BJ9S-nfS.js.br +0 -0
  157. package/payload/server/public/assets/maximize-2-BJ9S-nfS.js.gz +0 -0
  158. package/payload/server/public/assets/operator-Cay4rOOK.js.br +0 -0
  159. package/payload/server/public/assets/operator-Cay4rOOK.js.gz +0 -0
  160. package/payload/server/public/assets/page-Cci_j_y7.js.br +0 -0
  161. package/payload/server/public/assets/page-Cci_j_y7.js.gz +0 -0
  162. package/payload/server/public/assets/page-shuh4ES9.js.br +0 -0
  163. package/payload/server/public/assets/page-shuh4ES9.js.gz +0 -0
  164. package/payload/server/public/assets/public-BbJcQGsO.js.br +0 -0
  165. package/payload/server/public/assets/public-BbJcQGsO.js.gz +0 -0
  166. package/payload/server/public/assets/rotate-ccw-jeSVwUAv.js.br +0 -0
  167. package/payload/server/public/assets/rotate-ccw-jeSVwUAv.js.gz +0 -0
  168. package/payload/server/public/assets/routines-D3M1Wh3a.js.br +0 -0
  169. package/payload/server/public/assets/routines-D3M1Wh3a.js.gz +0 -0
  170. package/payload/server/public/assets/skills-Bhej2ub1.js.br +0 -0
  171. package/payload/server/public/assets/skills-Bhej2ub1.js.gz +0 -0
  172. package/payload/server/public/assets/tasks-qY-dtOI2.js +0 -1
  173. package/payload/server/public/assets/tasks-qY-dtOI2.js.br +0 -0
  174. package/payload/server/public/assets/tasks-qY-dtOI2.js.gz +0 -0
  175. package/payload/server/public/assets/time-entry-format-r5UY9rjG.js.br +0 -0
  176. package/payload/server/public/assets/time-entry-format-r5UY9rjG.js.gz +0 -0
  177. package/payload/server/public/assets/triangle-alert--PCKaewD.js.br +0 -2
  178. package/payload/server/public/assets/triangle-alert--PCKaewD.js.gz +0 -0
  179. package/payload/server/public/assets/useCopyFeedback-Dg5lNgqH.js.br +0 -0
  180. package/payload/server/public/assets/useCopyFeedback-Dg5lNgqH.js.gz +0 -0
  181. package/payload/server/public/assets/useSubAccountSwitcher-BYfe9vj8.css +0 -1
  182. package/payload/server/public/assets/useSubAccountSwitcher-BYfe9vj8.css.br +0 -0
  183. package/payload/server/public/assets/useSubAccountSwitcher-BYfe9vj8.css.gz +0 -0
  184. package/payload/server/public/assets/useVoiceRecorder-BY6ZsGMH.js.br +0 -0
  185. package/payload/server/public/assets/useVoiceRecorder-BY6ZsGMH.js.gz +0 -0
  186. package/payload/server/public/assets/wrench-Bw1FMolk.js.br +0 -0
  187. package/payload/server/public/assets/wrench-Bw1FMolk.js.gz +0 -0
  188. /package/payload/server/public/assets/{useSubAccountSwitcher-CnpeWIYq.js → useSubAccountSwitcher-BRHYXwGO.js} +0 -0
  189. /package/payload/server/public/assets/{useSubAccountSwitcher-CnpeWIYq.js.br → useSubAccountSwitcher-BRHYXwGO.js.br} +0 -0
  190. /package/payload/server/public/assets/{useSubAccountSwitcher-CnpeWIYq.js.gz → useSubAccountSwitcher-BRHYXwGO.js.gz} +0 -0
@@ -1,15 +1,59 @@
1
- // Options-page glue. Reads existing values, saves on click.
1
+ // Options-page glue. Reads existing values, requests browser access for the
2
+ // configured admin host, probes it, then saves.
3
+ //
4
+ // Task 2185 — the manifest's static host_permissions cover linkedin.com only,
5
+ // so a service-worker fetch to the admin host is an ordinary cross-origin
6
+ // request that Chrome preflights and the admin route never answers. The
7
+ // runtime grant requested here is what makes that fetch permission-exempt.
8
+ // Without it every capture dies in the browser and the server sees nothing.
2
9
 
3
10
  const $host = document.getElementById('admin-host')
4
11
  const $key = document.getElementById('session-key')
5
12
  const $btn = document.getElementById('save')
6
13
  const $status = document.getElementById('status')
7
14
 
15
+ const TAG = '[linkedin-ext-options]'
16
+
8
17
  function setStatus(text, isError) {
9
18
  $status.textContent = text
10
19
  $status.classList.toggle('error', !!isError)
11
20
  }
12
21
 
22
+ // Chrome match pattern covering every path on the admin host's origin.
23
+ function originPattern(adminHost) {
24
+ return new URL(adminHost).origin + '/*'
25
+ }
26
+
27
+ // What the operator reads when the probe fails. The reason tokens are for the
28
+ // console; this page says what actually went wrong in plain words.
29
+ const PROBE_COPY = {
30
+ unreachable: 'that host did not answer',
31
+ auth: 'that host rejected the session key',
32
+ server: 'that host answered with an unexpected status',
33
+ }
34
+
35
+ // Config-time probe. The body carries none of the five envelope fields, so
36
+ // the route answers 400 {error:'schema', reason:'envelope', missing:[all
37
+ // five]} — a signature no real capture can produce, because both content
38
+ // scripts always set all five. Nothing is dispatched and nothing is written.
39
+ async function probe(adminHost, sessionKey) {
40
+ let res
41
+ try {
42
+ res = await fetch(adminHost + '/api/admin/linkedin-ingest', {
43
+ method: 'POST',
44
+ headers: { 'content-type': 'application/json' },
45
+ body: JSON.stringify({ session_key: sessionKey }),
46
+ })
47
+ } catch (err) {
48
+ return { ok: false, reason: 'unreachable', detail: err && err.message ? err.message : String(err) }
49
+ }
50
+ if (res.status === 401) return { ok: false, reason: 'auth', detail: 'session-rejected' }
51
+ let body = null
52
+ try { body = await res.json() } catch { /* non-JSON body */ }
53
+ if (res.status === 400 && body && body.error === 'schema') return { ok: true }
54
+ return { ok: false, reason: 'server', detail: 'http-' + res.status }
55
+ }
56
+
13
57
  async function load() {
14
58
  const stored = await chrome.storage.local.get(['adminHost', 'sessionKey'])
15
59
  if (typeof stored.adminHost === 'string') $host.value = stored.adminHost
@@ -17,13 +61,44 @@ async function load() {
17
61
  }
18
62
 
19
63
  async function save() {
20
- const adminHost = $host.value.trim()
64
+ const adminHost = $host.value.trim().replace(/\/+$/, '')
21
65
  const sessionKey = $key.value.trim()
22
66
  if (!adminHost) return setStatus('Admin host is required.', true)
23
67
  if (!/^https?:\/\//.test(adminHost)) return setStatus('Admin host must start with http(s)://.', true)
24
68
  if (!sessionKey) return setStatus('Session key is required.', true)
69
+
70
+ let origin
71
+ try {
72
+ origin = originPattern(adminHost)
73
+ } catch {
74
+ return setStatus('Admin host is not a valid URL.', true)
75
+ }
76
+
77
+ // A rejected request() is not a refusal — it means Chrome would not even ask
78
+ // (a malformed match pattern, no user gesture). Left uncaught it aborts save()
79
+ // with no status at all, so the operator clicks Save and sees nothing happen.
80
+ let granted
81
+ try {
82
+ granted = await chrome.permissions.request({ origins: [origin] })
83
+ } catch (err) {
84
+ const detail = err && err.message ? err.message : String(err)
85
+ console.error(TAG + ' op=permission origin=' + origin + ' granted=error detail=' + detail)
86
+ return setStatus('Chrome refused to ask for access to ' + origin + ': ' + detail, true)
87
+ }
88
+ console.log(TAG + ' op=permission origin=' + origin + ' granted=' + granted)
89
+ if (!granted) {
90
+ return setStatus(
91
+ 'Access to ' + origin + ' was declined, so nothing was saved. The pill cannot post without it.',
92
+ true,
93
+ )
94
+ }
95
+
96
+ const reachable = await probe(adminHost, sessionKey)
25
97
  await chrome.storage.local.set({ adminHost: adminHost, sessionKey: sessionKey })
26
- setStatus('Saved.', false)
98
+ if (reachable.ok) {
99
+ return setStatus('Saved. The admin host is reachable and accepted the session key.', false)
100
+ }
101
+ setStatus('Saved, but ' + PROBE_COPY[reachable.reason] + ' (' + reachable.detail + ')', true)
27
102
  }
28
103
 
29
104
  $btn.addEventListener('click', save)
@@ -4075,6 +4075,214 @@ var clientIpMiddleware = async (c, next) => {
4075
4075
  await next();
4076
4076
  };
4077
4077
 
4078
+ // app/lib/cdp-screencast.ts
4079
+ var CDP_WS_PATH = "/cdp-screencast";
4080
+ var TARGET_ID_RE = /^[0-9A-Fa-f]{32}$/;
4081
+ function selectPageTargets(parsed) {
4082
+ if (!Array.isArray(parsed)) return [];
4083
+ const out = [];
4084
+ for (const raw2 of parsed) {
4085
+ if (raw2?.type !== "page") continue;
4086
+ const id = typeof raw2.id === "string" ? raw2.id : "";
4087
+ if (!TARGET_ID_RE.test(id)) continue;
4088
+ const url = typeof raw2.url === "string" ? raw2.url : "";
4089
+ const title = typeof raw2.title === "string" && raw2.title.length > 0 ? raw2.title : url;
4090
+ out.push({ id, title, url });
4091
+ }
4092
+ return out;
4093
+ }
4094
+ var CLIENT_SCREENCAST_OPS = [
4095
+ "request",
4096
+ "screencast-started",
4097
+ "start-failed",
4098
+ "send-failed",
4099
+ "frame",
4100
+ "input",
4101
+ "teardown"
4102
+ ];
4103
+ function isClientScreencastOp(v) {
4104
+ return typeof v === "string" && CLIENT_SCREENCAST_OPS.includes(v);
4105
+ }
4106
+
4107
+ // app/lib/cdp-screencast-server.ts
4108
+ var HOP_BY_HOP = /* @__PURE__ */ new Set([
4109
+ "connection",
4110
+ "keep-alive",
4111
+ "proxy-authenticate",
4112
+ "proxy-authorization",
4113
+ "te",
4114
+ "trailer",
4115
+ "transfer-encoding",
4116
+ "upgrade"
4117
+ ]);
4118
+ var DROP_UPSTREAM = /* @__PURE__ */ new Set(["origin", "cookie"]);
4119
+ function decideCdpUpgrade(input) {
4120
+ const corrId = newCorrId();
4121
+ const qsIndex = input.url.indexOf("?");
4122
+ const pathname = qsIndex === -1 ? input.url : input.url.slice(0, qsIndex);
4123
+ const query = qsIndex === -1 ? "" : input.url.slice(qsIndex + 1);
4124
+ const sid = sanitizeClientCorrId(parseQueryParam(query, "sid"));
4125
+ if (pathname !== CDP_WS_PATH) {
4126
+ return { allow: false, reason: "wrong-path", status: 0, sid, corrId };
4127
+ }
4128
+ const access = input.canAccess({
4129
+ host: input.host,
4130
+ remoteAddress: input.remoteAddress,
4131
+ xForwardedFor: input.xForwardedFor,
4132
+ cookieHeader: input.cookieHeader,
4133
+ isPublicHost: input.isPublicHost
4134
+ });
4135
+ if (!access.allow) {
4136
+ const publicHost = access.reason === "public-host";
4137
+ return {
4138
+ allow: false,
4139
+ reason: publicHost ? "public-host" : "unauthorized",
4140
+ status: publicHost ? 404 : 401,
4141
+ sid,
4142
+ corrId
4143
+ };
4144
+ }
4145
+ const originHost = parseOriginHost(input.origin);
4146
+ if (!originHost) {
4147
+ return { allow: false, reason: "origin-missing-or-invalid", status: 403, sid, corrId };
4148
+ }
4149
+ if (originHost !== input.host) {
4150
+ return { allow: false, reason: "origin-mismatch", status: 403, sid, corrId };
4151
+ }
4152
+ if (input.isPublicHost(originHost)) {
4153
+ return { allow: false, reason: "origin-public-host", status: 403, sid, corrId };
4154
+ }
4155
+ if (input.isOperatorHost(originHost)) {
4156
+ return { allow: false, reason: "origin-operator-host", status: 403, sid, corrId };
4157
+ }
4158
+ const targetId = parseQueryParam(query, "targetId");
4159
+ if (!targetId || !TARGET_ID_RE.test(targetId)) {
4160
+ return { allow: false, reason: "target-id-invalid", status: 400, sid, corrId };
4161
+ }
4162
+ return { allow: true, targetId, sid, corrId };
4163
+ }
4164
+ function buildCdpUpstreamRequest(input) {
4165
+ const lines = [];
4166
+ lines.push(`${input.method} /devtools/page/${input.targetId} HTTP/${input.httpVersion}`);
4167
+ lines.push(`host: ${input.upstreamHost}:${input.upstreamPort}`);
4168
+ for (const [rawName, value] of Object.entries(input.headers)) {
4169
+ const name = rawName.toLowerCase();
4170
+ if (name === "host") continue;
4171
+ if (HOP_BY_HOP.has(name)) continue;
4172
+ if (DROP_UPSTREAM.has(name)) continue;
4173
+ if (value == null) continue;
4174
+ if (Array.isArray(value)) {
4175
+ for (const v of value) lines.push(`${name}: ${v}`);
4176
+ } else {
4177
+ lines.push(`${name}: ${value}`);
4178
+ }
4179
+ }
4180
+ const upgrade = headerString(input.headers.upgrade);
4181
+ const connection = headerString(input.headers.connection);
4182
+ if (upgrade) lines.push(`upgrade: ${upgrade}`);
4183
+ if (connection) lines.push(`connection: ${connection}`);
4184
+ return lines.join("\r\n") + "\r\n\r\n";
4185
+ }
4186
+ async function fetchPageTargets(deps) {
4187
+ if (deps.platform !== "linux") {
4188
+ return {
4189
+ ok: false,
4190
+ status: 501,
4191
+ error: "The device browser is available on the Linux display stack only."
4192
+ };
4193
+ }
4194
+ let res;
4195
+ try {
4196
+ res = await deps.fetchImpl(`http://127.0.0.1:${deps.cdpPort}/json/list`, {
4197
+ signal: AbortSignal.timeout(5e3)
4198
+ });
4199
+ } catch {
4200
+ return { ok: false, status: 502, error: "Chromium is not reachable on this device." };
4201
+ }
4202
+ if (!res.ok) {
4203
+ return { ok: false, status: 502, error: `CDP /json/list returned ${res.status}` };
4204
+ }
4205
+ let parsed;
4206
+ try {
4207
+ parsed = await res.json();
4208
+ } catch {
4209
+ return { ok: false, status: 502, error: "CDP /json/list response was not JSON." };
4210
+ }
4211
+ return { ok: true, targets: selectPageTargets(parsed) };
4212
+ }
4213
+ var EVENT_DETAIL_KEYS = [
4214
+ "seq",
4215
+ "kind",
4216
+ "count",
4217
+ "format",
4218
+ "everyNthFrame",
4219
+ "stopSent",
4220
+ "ackMs",
4221
+ "err",
4222
+ "method"
4223
+ ];
4224
+ var DETAIL_MAX_LEN = 128;
4225
+ function parseScreencastEvent(body) {
4226
+ if (typeof body !== "object" || body === null) return { ok: false };
4227
+ const raw2 = body;
4228
+ if (!isClientScreencastOp(raw2.op)) return { ok: false };
4229
+ const detail = {
4230
+ sid: sanitizeClientCorrId(typeof raw2.sid === "string" ? raw2.sid : null)
4231
+ };
4232
+ for (const key of EVENT_DETAIL_KEYS) {
4233
+ const v = raw2[key];
4234
+ if (typeof v === "number" || typeof v === "boolean") {
4235
+ detail[key] = v;
4236
+ } else if (typeof v === "string") {
4237
+ detail[key] = sanitizeLogValue(v);
4238
+ }
4239
+ }
4240
+ return { ok: true, op: raw2.op, detail };
4241
+ }
4242
+ function sanitizeLogValue(v) {
4243
+ return v.replace(/[\r\n]+/g, " ").slice(0, DETAIL_MAX_LEN);
4244
+ }
4245
+ function renderFields(fields) {
4246
+ return Object.entries(fields).filter(([, v]) => v !== void 0).map(([k, v]) => {
4247
+ const s = v === null ? "null" : String(v);
4248
+ return `${k}=${sanitizeLogValue(s)}`;
4249
+ }).join(" ");
4250
+ }
4251
+ function screencastLog(op, fields = {}) {
4252
+ const rendered = renderFields(fields);
4253
+ console.error(`[browser/screencast] op=${sanitizeLogValue(op)}${rendered ? " " + rendered : ""}`);
4254
+ }
4255
+ function cdpWsCensusLog(fields) {
4256
+ console.error(`[cdp-ws-census] ${renderFields(fields)}`);
4257
+ }
4258
+ function parseQueryParam(query, key) {
4259
+ if (!query) return null;
4260
+ for (const pair of query.split("&")) {
4261
+ const eq = pair.indexOf("=");
4262
+ const k = eq === -1 ? pair : pair.slice(0, eq);
4263
+ if (k !== key) continue;
4264
+ const v = eq === -1 ? "" : pair.slice(eq + 1);
4265
+ try {
4266
+ return decodeURIComponent(v);
4267
+ } catch {
4268
+ return null;
4269
+ }
4270
+ }
4271
+ return null;
4272
+ }
4273
+ function headerString(value) {
4274
+ if (value == null) return void 0;
4275
+ return Array.isArray(value) ? value[0] : value;
4276
+ }
4277
+ function parseOriginHost(origin) {
4278
+ if (!origin) return null;
4279
+ try {
4280
+ return new URL(origin).hostname;
4281
+ } catch {
4282
+ return null;
4283
+ }
4284
+ }
4285
+
4078
4286
  export {
4079
4287
  HTTPException,
4080
4288
  HtmlEscapedCallbackPhase,
@@ -4137,5 +4345,12 @@ export {
4137
4345
  requireAdminSession,
4138
4346
  tryCookieBridgeForConversation,
4139
4347
  requireAdminCookie,
4140
- clientIpMiddleware
4348
+ clientIpMiddleware,
4349
+ CDP_WS_PATH,
4350
+ decideCdpUpgrade,
4351
+ buildCdpUpstreamRequest,
4352
+ fetchPageTargets,
4353
+ parseScreencastEvent,
4354
+ screencastLog,
4355
+ cdpWsCensusLog
4141
4356
  };
@@ -1,8 +1,12 @@
1
1
  import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
2
2
  import {
3
+ CDP_WS_PATH,
3
4
  Hono,
5
+ buildCdpUpstreamRequest,
4
6
  canAccessAdmin,
7
+ cdpWsCensusLog,
5
8
  clientIpMiddleware,
9
+ decideCdpUpgrade,
6
10
  describeRemoteSession,
7
11
  getOperatorDomains,
8
12
  getRequestListener,
@@ -12,10 +16,12 @@ import {
12
16
  requireAdminCookie,
13
17
  requirePortEnv,
14
18
  sanitizeClientCorrId,
19
+ screencastLog,
15
20
  vncLog,
16
21
  websockifyLog
17
- } from "./chunk-WT2PFGY4.js";
22
+ } from "./chunk-KY3BBT67.js";
18
23
  import {
24
+ CDP_PORT,
19
25
  LOG_DIR,
20
26
  WEBSOCKIFY_PORT
21
27
  } from "./chunk-XCR5D2G3.js";
@@ -23,7 +29,7 @@ import "./chunk-PFF6I7KP.js";
23
29
 
24
30
  // server/edge.ts
25
31
  import { createServer, request as httpRequest } from "http";
26
- import { createConnection as createConnection2 } from "net";
32
+ import { createConnection as createConnection3 } from "net";
27
33
  import { readFileSync as readFileSync4, existsSync as existsSync4, watchFile } from "fs";
28
34
  import { homedir } from "os";
29
35
  import { join as join4 } from "path";
@@ -315,10 +321,10 @@ function parseOriginHost(origin) {
315
321
  return null;
316
322
  }
317
323
  }
318
- function writeStatusAndDestroy(socket, status, statusText) {
324
+ function writeStatusAndDestroy(socket, status, statusText2) {
319
325
  try {
320
326
  socket.write(
321
- `HTTP/1.1 ${status} ${statusText}\r
327
+ `HTTP/1.1 ${status} ${statusText2}\r
322
328
  Connection: close\r
323
329
  Content-Length: 0\r
324
330
  \r
@@ -329,6 +335,215 @@ Content-Length: 0\r
329
335
  socket.destroy();
330
336
  }
331
337
 
338
+ // server/cdp-ws-proxy.ts
339
+ import { createConnection as createConnection2 } from "net";
340
+ var UPSTREAM_TIMEOUT_MS2 = 5e3;
341
+ var REAP_INTERVAL_MS = 6e4;
342
+ var liveSockets = /* @__PURE__ */ new Set();
343
+ var reaper = null;
344
+ function cdpSessionCount() {
345
+ const sids = /* @__PURE__ */ new Set();
346
+ for (const e of liveSockets) sids.add(e.sid);
347
+ return sids.size;
348
+ }
349
+ function ensureReaperArmed() {
350
+ if (reaper) return;
351
+ reaper = setInterval(sweepOrphans, REAP_INTERVAL_MS);
352
+ if (typeof reaper.unref === "function") reaper.unref();
353
+ }
354
+ function disarmReaperIfIdle() {
355
+ if (liveSockets.size > 0 || !reaper) return;
356
+ clearInterval(reaper);
357
+ reaper = null;
358
+ }
359
+ function sweepOrphans() {
360
+ reapOrphanedCdpSockets();
361
+ disarmReaperIfIdle();
362
+ }
363
+ function registerCdpSocket(entry) {
364
+ liveSockets.add(entry);
365
+ ensureReaperArmed();
366
+ cdpWsCensusLog({ liveSockets: liveSockets.size, sessions: cdpSessionCount() });
367
+ }
368
+ function unregisterCdpSocket(entry) {
369
+ if (!liveSockets.delete(entry)) return;
370
+ disarmReaperIfIdle();
371
+ cdpWsCensusLog({ liveSockets: liveSockets.size, sessions: cdpSessionCount() });
372
+ }
373
+ function reapOrphanedCdpSockets() {
374
+ let reaped = 0;
375
+ for (const entry of [...liveSockets]) {
376
+ if (!entry.client.destroyed) continue;
377
+ if (!entry.upstream.destroyed) {
378
+ try {
379
+ entry.upstream.destroy();
380
+ } catch {
381
+ }
382
+ }
383
+ liveSockets.delete(entry);
384
+ reaped += 1;
385
+ }
386
+ if (reaped > 0) {
387
+ screencastLog("orphan-reap", { closed: reaped, liveSockets: liveSockets.size });
388
+ cdpWsCensusLog({ liveSockets: liveSockets.size, sessions: cdpSessionCount() });
389
+ }
390
+ return reaped;
391
+ }
392
+ function attachCdpWsProxy(server2, opts) {
393
+ const upstreamHost = opts.upstreamHost ?? "127.0.0.1";
394
+ const upstreamPort = opts.upstreamPort ?? CDP_PORT;
395
+ const isOperatorHost2 = opts.isOperatorHost ?? (() => false);
396
+ server2.on("upgrade", (req, clientSocket, head) => {
397
+ try {
398
+ handleCdpUpgrade(req, clientSocket, head, {
399
+ isPublicHost: opts.isPublicHost,
400
+ isOperatorHost: isOperatorHost2,
401
+ upstreamHost,
402
+ upstreamPort
403
+ });
404
+ } catch (err) {
405
+ screencastLog("gate", { decision: "reject-handler-exception", err: err.message });
406
+ clientSocket.destroy();
407
+ }
408
+ });
409
+ }
410
+ function handleCdpUpgrade(req, clientSocket, head, opts) {
411
+ const host = (req.headers.host ?? "").split(":")[0];
412
+ const decision = decideCdpUpgrade({
413
+ url: req.url ?? "",
414
+ host,
415
+ origin: headerString2(req.headers.origin),
416
+ cookieHeader: headerString2(req.headers.cookie),
417
+ remoteAddress: req.socket.remoteAddress,
418
+ xForwardedFor: headerString2(req.headers["x-forwarded-for"]),
419
+ isPublicHost: opts.isPublicHost,
420
+ isOperatorHost: opts.isOperatorHost,
421
+ canAccess: canAccessAdmin
422
+ });
423
+ if (!decision.allow && decision.reason === "wrong-path") return;
424
+ if (!decision.allow) {
425
+ screencastLog("gate", {
426
+ sid: decision.sid,
427
+ corrId: decision.corrId,
428
+ decision: `reject-${decision.reason}`,
429
+ host,
430
+ ip: req.socket.remoteAddress
431
+ });
432
+ writeStatusAndDestroy2(clientSocket, decision.status, statusText(decision.status));
433
+ return;
434
+ }
435
+ const sid = decision.sid ?? decision.corrId;
436
+ screencastLog("gate", {
437
+ sid,
438
+ corrId: decision.corrId,
439
+ decision: "allow",
440
+ host,
441
+ cdpPort: opts.upstreamPort
442
+ });
443
+ const upstream = createConnection2({ host: opts.upstreamHost, port: opts.upstreamPort });
444
+ upstream.setTimeout(UPSTREAM_TIMEOUT_MS2);
445
+ const entry = {
446
+ sid,
447
+ client: clientSocket,
448
+ upstream
449
+ };
450
+ let bytesClientToUpstream = 0;
451
+ let bytesUpstreamToClient = 0;
452
+ let closedBy = null;
453
+ let proxyOpened = false;
454
+ const openedAt = Date.now();
455
+ const finish = (side, reason) => {
456
+ if (closedBy) return;
457
+ closedBy = side;
458
+ if (proxyOpened) {
459
+ unregisterCdpSocket(entry);
460
+ screencastLog("socket-closed", {
461
+ sid,
462
+ closedBy: side,
463
+ reason,
464
+ clientBytes: bytesClientToUpstream,
465
+ upstreamBytes: bytesUpstreamToClient,
466
+ durationMs: Date.now() - openedAt,
467
+ liveSockets: liveSockets.size
468
+ });
469
+ }
470
+ clientSocket.destroy();
471
+ upstream.destroy();
472
+ };
473
+ upstream.once("connect", () => {
474
+ upstream.setTimeout(0);
475
+ proxyOpened = true;
476
+ registerCdpSocket(entry);
477
+ screencastLog("cdp-connected", {
478
+ sid,
479
+ wsTarget: `/devtools/page/${decision.targetId}`,
480
+ liveSockets: liveSockets.size
481
+ });
482
+ upstream.write(
483
+ buildCdpUpstreamRequest({
484
+ method: req.method ?? "GET",
485
+ httpVersion: req.httpVersion,
486
+ headers: req.headers,
487
+ targetId: decision.targetId,
488
+ upstreamHost: opts.upstreamHost,
489
+ upstreamPort: opts.upstreamPort
490
+ })
491
+ );
492
+ if (head && head.length > 0) upstream.write(head);
493
+ clientSocket.on("data", (chunk) => {
494
+ bytesClientToUpstream += chunk.length;
495
+ });
496
+ upstream.on("data", (chunk) => {
497
+ bytesUpstreamToClient += chunk.length;
498
+ });
499
+ clientSocket.pipe(upstream);
500
+ upstream.pipe(clientSocket);
501
+ clientSocket.once("close", (hadError) => finish("client", hadError ? "error" : "normal"));
502
+ upstream.once("close", (hadError) => finish("upstream", hadError ? "error" : "normal"));
503
+ clientSocket.once("error", (err) => {
504
+ screencastLog("socket-error", { sid, side: "client", err: err.message });
505
+ finish("client", "error");
506
+ });
507
+ upstream.once("error", (err) => {
508
+ screencastLog("socket-error", { sid, side: "upstream", err: err.message });
509
+ finish("upstream", "error");
510
+ });
511
+ });
512
+ upstream.once("timeout", () => {
513
+ if (proxyOpened) return;
514
+ screencastLog("cdp-connect-failed", { sid, err: "timeout", timeoutMs: UPSTREAM_TIMEOUT_MS2 });
515
+ writeStatusAndDestroy2(clientSocket, 504, "Gateway Timeout");
516
+ upstream.destroy();
517
+ });
518
+ upstream.once("error", (err) => {
519
+ if (proxyOpened) return;
520
+ screencastLog("cdp-connect-failed", { sid, err: err.message });
521
+ writeStatusAndDestroy2(clientSocket, 502, "Bad Gateway");
522
+ upstream.destroy();
523
+ });
524
+ }
525
+ function headerString2(value) {
526
+ if (value == null) return void 0;
527
+ return Array.isArray(value) ? value[0] : value;
528
+ }
529
+ function statusText(status) {
530
+ if (status === 400) return "Bad Request";
531
+ if (status === 401) return "Unauthorized";
532
+ if (status === 404) return "Not Found";
533
+ return "Forbidden";
534
+ }
535
+ function writeStatusAndDestroy2(socket, status, text) {
536
+ try {
537
+ socket.write(`HTTP/1.1 ${status} ${text}\r
538
+ Connection: close\r
539
+ Content-Length: 0\r
540
+ \r
541
+ `);
542
+ } catch {
543
+ }
544
+ socket.destroy();
545
+ }
546
+
332
547
  // server/routes/admin/version.ts
333
548
  import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
334
549
  import { resolve, join as join2 } from "path";
@@ -764,7 +979,7 @@ function forwardHttp(clientReq, clientRes) {
764
979
  clientReq.pipe(upstream);
765
980
  }
766
981
  function forwardUpgrade(req, clientSocket, head) {
767
- const upstream = createConnection2({ host: UPSTREAM_HOST, port: UPSTREAM_PORT });
982
+ const upstream = createConnection3({ host: UPSTREAM_HOST, port: UPSTREAM_PORT });
768
983
  upstream.setTimeout(5e3);
769
984
  upstream.once("connect", () => {
770
985
  upstream.setTimeout(0);
@@ -838,16 +1053,21 @@ attachVncWsProxy(server, {
838
1053
  upstreamPort: WEBSOCKIFY_PORT2,
839
1054
  brand: BRAND.productName
840
1055
  });
1056
+ attachCdpWsProxy(server, {
1057
+ isPublicHost,
1058
+ isOperatorHost: (h) => isOperatorHost(h, getOperatorDomains())
1059
+ });
841
1060
  server.on("upgrade", (req, socket, head) => {
842
1061
  const url = req.url ?? "";
843
1062
  const qsIndex = url.indexOf("?");
844
1063
  const pathname = qsIndex === -1 ? url : url.slice(0, qsIndex);
845
- if (pathname === "/websockify") return;
1064
+ if (pathname === "/websockify" || pathname === CDP_WS_PATH) return;
846
1065
  forwardUpgrade(req, socket, head);
847
1066
  });
848
1067
  server.listen(EDGE_PORT, EDGE_HOSTNAME, () => {
849
1068
  console.log(`[edge] listening on http://${EDGE_HOSTNAME}:${EDGE_PORT}`);
850
1069
  console.log(`[edge] brand=${BRAND.productName} holding-logo=${BRAND.logoDataUri ? "inlined" : "missing"} assets-dir=${BRAND_ASSETS_DIR || "(none)"}`);
851
1070
  console.log(`[edge] /websockify \u2192 ${WEBSOCKIFY_HOST}:${WEBSOCKIFY_PORT2}`);
1071
+ console.log(`[edge] ${CDP_WS_PATH} \u2192 127.0.0.1:${CDP_PORT} (screencast)`);
852
1072
  console.log(`[edge] everything else \u2192 ${UPSTREAM_HOST}:${UPSTREAM_PORT}`);
853
1073
  });
@@ -5,12 +5,12 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>Activity — Maxy</title>
7
7
  <link rel="icon" href="/favicon.ico">
8
- <script type="module" crossorigin src="/assets/activity-DGQt5ys_.js"></script>
8
+ <script type="module" crossorigin src="/assets/activity-aVvbaVd4.js"></script>
9
9
  <link rel="modulepreload" crossorigin href="/assets/chunk-CltuBf4Z.js">
10
- <link rel="modulepreload" crossorigin href="/assets/useSubAccountSwitcher-CnpeWIYq.js">
11
- <link rel="modulepreload" crossorigin href="/assets/AdminShell-YctTrR-D.js">
12
- <link rel="modulepreload" crossorigin href="/assets/triangle-alert--PCKaewD.js">
13
- <link rel="stylesheet" crossorigin href="/assets/useSubAccountSwitcher-BYfe9vj8.css">
10
+ <link rel="modulepreload" crossorigin href="/assets/useSubAccountSwitcher-BRHYXwGO.js">
11
+ <link rel="modulepreload" crossorigin href="/assets/AdminShell-MM3_yp3N.js">
12
+ <link rel="modulepreload" crossorigin href="/assets/triangle-alert-CpwrbdYq.js">
13
+ <link rel="stylesheet" crossorigin href="/assets/useSubAccountSwitcher-CJzJ9xV3.css">
14
14
  <link rel="stylesheet" href="/brand-defaults.css">
15
15
  </head>
16
16
  <body>
@@ -5,11 +5,11 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>Agents — Maxy</title>
7
7
  <link rel="icon" href="/favicon.ico">
8
- <script type="module" crossorigin src="/assets/agents-Bc2LZjwC.js"></script>
8
+ <script type="module" crossorigin src="/assets/agents-CrpVTPRG.js"></script>
9
9
  <link rel="modulepreload" crossorigin href="/assets/chunk-CltuBf4Z.js">
10
- <link rel="modulepreload" crossorigin href="/assets/useSubAccountSwitcher-CnpeWIYq.js">
11
- <link rel="modulepreload" crossorigin href="/assets/AdminShell-YctTrR-D.js">
12
- <link rel="stylesheet" crossorigin href="/assets/useSubAccountSwitcher-BYfe9vj8.css">
10
+ <link rel="modulepreload" crossorigin href="/assets/useSubAccountSwitcher-BRHYXwGO.js">
11
+ <link rel="modulepreload" crossorigin href="/assets/AdminShell-MM3_yp3N.js">
12
+ <link rel="stylesheet" crossorigin href="/assets/useSubAccountSwitcher-CJzJ9xV3.css">
13
13
  <link rel="stylesheet" href="/brand-defaults.css">
14
14
  </head>
15
15
  <body>