aicodeman 1.8.0 → 1.8.2

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 (123) hide show
  1. package/README.md +23 -6
  2. package/README.zh-CN.md +22 -5
  3. package/dist/config/webview-limits.d.ts +36 -0
  4. package/dist/config/webview-limits.d.ts.map +1 -0
  5. package/dist/config/webview-limits.js +40 -0
  6. package/dist/config/webview-limits.js.map +1 -0
  7. package/dist/types/index.d.ts +1 -0
  8. package/dist/types/index.d.ts.map +1 -1
  9. package/dist/types/index.js +1 -0
  10. package/dist/types/index.js.map +1 -1
  11. package/dist/types/webview.d.ts +84 -0
  12. package/dist/types/webview.d.ts.map +1 -0
  13. package/dist/types/webview.js +21 -0
  14. package/dist/types/webview.js.map +1 -0
  15. package/dist/web/middleware/auth.d.ts.map +1 -1
  16. package/dist/web/middleware/auth.js +75 -3
  17. package/dist/web/middleware/auth.js.map +1 -1
  18. package/dist/web/public/admin-ui.js.gz +0 -0
  19. package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
  20. package/dist/web/public/app.adc992cd.js +38 -0
  21. package/dist/web/public/app.adc992cd.js.br +0 -0
  22. package/dist/web/public/app.adc992cd.js.gz +0 -0
  23. package/dist/web/public/{constants.21a0a83e.js → constants.e7175b95.js} +3 -0
  24. package/dist/web/public/constants.e7175b95.js.br +0 -0
  25. package/dist/web/public/{constants.21a0a83e.js.gz → constants.e7175b95.js.gz} +0 -0
  26. package/dist/web/public/cron-ui.js.gz +0 -0
  27. package/dist/web/public/i18n.4dcf1b87.js.gz +0 -0
  28. package/dist/web/public/image-input.ee16ad88.js.gz +0 -0
  29. package/dist/web/public/index.html +86 -12
  30. package/dist/web/public/index.html.br +0 -0
  31. package/dist/web/public/index.html.gz +0 -0
  32. package/dist/web/public/input-cjk.63794d0b.js.gz +0 -0
  33. package/dist/web/public/keyboard-accessory.3f91acef.js.gz +0 -0
  34. package/dist/web/public/mobile-handlers.bc2b8c1d.js.gz +0 -0
  35. package/dist/web/public/mobile.2ebdbb03.css +1 -0
  36. package/dist/web/public/mobile.2ebdbb03.css.br +0 -0
  37. package/dist/web/public/mobile.2ebdbb03.css.gz +0 -0
  38. package/dist/web/public/notification-manager.fca811fd.js.gz +0 -0
  39. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  40. package/dist/web/public/panels-ui.58582d4c.js.gz +0 -0
  41. package/dist/web/public/ralph-panel.6de2d0f8.js.gz +0 -0
  42. package/dist/web/public/ralph-wizard.13a1831e.js.gz +0 -0
  43. package/dist/web/public/respawn-ui.2d249da9.js.gz +0 -0
  44. package/dist/web/public/sanitize-html.bc7078d6.js.gz +0 -0
  45. package/dist/web/public/{session-ui.713c8836.js → session-ui.fd39c53c.js} +2 -2
  46. package/dist/web/public/session-ui.fd39c53c.js.br +0 -0
  47. package/dist/web/public/session-ui.fd39c53c.js.gz +0 -0
  48. package/dist/web/public/{settings-ui.0259fbe8.js → settings-ui.478c8242.js} +2 -2
  49. package/dist/web/public/settings-ui.478c8242.js.br +0 -0
  50. package/dist/web/public/settings-ui.478c8242.js.gz +0 -0
  51. package/dist/web/public/styles.3f797179.css +1 -0
  52. package/dist/web/public/styles.3f797179.css.br +0 -0
  53. package/dist/web/public/styles.3f797179.css.gz +0 -0
  54. package/dist/web/public/subagent-windows.69b60c35.js.gz +0 -0
  55. package/dist/web/public/sw.js.gz +0 -0
  56. package/dist/web/public/terminal-ui.2d582bf6.js +3 -0
  57. package/dist/web/public/terminal-ui.2d582bf6.js.br +0 -0
  58. package/dist/web/public/terminal-ui.2d582bf6.js.gz +0 -0
  59. package/dist/web/public/ultracode-panel.js.gz +0 -0
  60. package/dist/web/public/ultracode-windows.js.gz +0 -0
  61. package/dist/web/public/upload.html.gz +0 -0
  62. package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
  63. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  64. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  65. package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
  66. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  67. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  68. package/dist/web/public/vendor/xterm-zerolag-input.137ad9f0.js.gz +0 -0
  69. package/dist/web/public/vendor/xterm.css.gz +0 -0
  70. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  71. package/dist/web/public/voice-input.085e9e73.js.gz +0 -0
  72. package/dist/web/public/webview-tabs.js +445 -0
  73. package/dist/web/public/webview-tabs.js.br +0 -0
  74. package/dist/web/public/webview-tabs.js.gz +0 -0
  75. package/dist/web/routes/index.d.ts +1 -0
  76. package/dist/web/routes/index.d.ts.map +1 -1
  77. package/dist/web/routes/index.js +1 -0
  78. package/dist/web/routes/index.js.map +1 -1
  79. package/dist/web/routes/webview-routes.d.ts +50 -0
  80. package/dist/web/routes/webview-routes.d.ts.map +1 -0
  81. package/dist/web/routes/webview-routes.js +541 -0
  82. package/dist/web/routes/webview-routes.js.map +1 -0
  83. package/dist/web/schemas.d.ts +26 -0
  84. package/dist/web/schemas.d.ts.map +1 -1
  85. package/dist/web/schemas.js +34 -0
  86. package/dist/web/schemas.js.map +1 -1
  87. package/dist/web/server.d.ts.map +1 -1
  88. package/dist/web/server.js +11 -5
  89. package/dist/web/server.js.map +1 -1
  90. package/dist/web/sse-events.d.ts +7 -1
  91. package/dist/web/sse-events.d.ts.map +1 -1
  92. package/dist/web/sse-events.js +8 -1
  93. package/dist/web/sse-events.js.map +1 -1
  94. package/dist/web/webview-proxy.d.ts +181 -0
  95. package/dist/web/webview-proxy.d.ts.map +1 -0
  96. package/dist/web/webview-proxy.js +491 -0
  97. package/dist/web/webview-proxy.js.map +1 -0
  98. package/dist/webview-capabilities.d.ts +55 -0
  99. package/dist/webview-capabilities.d.ts.map +1 -0
  100. package/dist/webview-capabilities.js +102 -0
  101. package/dist/webview-capabilities.js.map +1 -0
  102. package/dist/webview-store.d.ts +16 -0
  103. package/dist/webview-store.d.ts.map +1 -0
  104. package/dist/webview-store.js +34 -0
  105. package/dist/webview-store.js.map +1 -0
  106. package/package.json +11 -2
  107. package/dist/web/public/app.2e95c82d.js +0 -38
  108. package/dist/web/public/app.2e95c82d.js.br +0 -0
  109. package/dist/web/public/app.2e95c82d.js.gz +0 -0
  110. package/dist/web/public/constants.21a0a83e.js.br +0 -0
  111. package/dist/web/public/mobile.f2dcfa13.css +0 -1
  112. package/dist/web/public/mobile.f2dcfa13.css.br +0 -0
  113. package/dist/web/public/mobile.f2dcfa13.css.gz +0 -0
  114. package/dist/web/public/session-ui.713c8836.js.br +0 -0
  115. package/dist/web/public/session-ui.713c8836.js.gz +0 -0
  116. package/dist/web/public/settings-ui.0259fbe8.js.br +0 -0
  117. package/dist/web/public/settings-ui.0259fbe8.js.gz +0 -0
  118. package/dist/web/public/styles.d2f415e2.css +0 -1
  119. package/dist/web/public/styles.d2f415e2.css.br +0 -0
  120. package/dist/web/public/styles.d2f415e2.css.gz +0 -0
  121. package/dist/web/public/terminal-ui.9ea677e9.js +0 -3
  122. package/dist/web/public/terminal-ui.9ea677e9.js.br +0 -0
  123. package/dist/web/public/terminal-ui.9ea677e9.js.gz +0 -0
@@ -0,0 +1,181 @@
1
+ /**
2
+ * @fileoverview Pure helpers for the web-tab reverse proxy. No I/O, no Fastify.
3
+ *
4
+ * The proxy exists because an iframe pointing straight at a dashboard cannot work
5
+ * in the deployment that matters: prod serves HTTPS (behind `tailscale serve`), so
6
+ * a plain-HTTP dashboard is hard-blocked as mixed content; many dashboards also
7
+ * refuse framing outright via `X-Frame-Options` / `frame-ancestors`; and Codeman's
8
+ * own CSP (`default-src 'self'`) blocks cross-origin frames anyway. Serving the
9
+ * dashboard through Codeman's own origin dissolves all three at once, and keeps
10
+ * the production CSP byte-for-byte unchanged because `/webview/...` is `'self'`.
11
+ *
12
+ * ## Origin-scoped, not path-scoped
13
+ *
14
+ * `/webview/<cap>/x/y` always maps to `<upstream origin>/x/y`, never to
15
+ * `<upstream origin><saved path>/x/y`. Dashboards reference assets with
16
+ * root-absolute paths (`/public/build/app.js`), so origin-scoping is the only
17
+ * mapping under which those resolve. The saved URL's own path+query is used for
18
+ * exactly one thing: what `/webview/<cap>/` itself serves (the landing page).
19
+ *
20
+ * ## What gets rewritten, and why each one is load-bearing
21
+ *
22
+ * - `x-frame-options` / CSP `frame-ancestors`: dropped, else the browser refuses
23
+ * to render the frame. This is the whole point of the proxy.
24
+ * - `content-encoding` / `content-length`: dropped, because undici's `fetch`
25
+ * already decoded the body. Forwarding them makes the browser try to gunzip
26
+ * plaintext.
27
+ * - `authorization` + the `codeman_session` cookie: stripped on the way OUT. In
28
+ * trusted mode the iframe is same-origin, so the browser attaches Codeman's own
29
+ * Basic-auth header and session cookie to every proxied request. Forwarding
30
+ * those would hand CODEMAN_PASSWORD to the dashboard.
31
+ * - `Location` and `Set-Cookie`: remapped into the proxy path, else a redirect or
32
+ * a login cookie escapes the prefix and lands on Codeman's root.
33
+ * - `<base href>` + root-absolute attribute rewriting: relative and `/`-rooted
34
+ * URLs in the HTML resolve back through the proxy instead of hitting Codeman.
35
+ *
36
+ * No `X-Forwarded-*` is sent deliberately: apps that honor it generate absolute
37
+ * URLs against Codeman's root, which would bypass the `/webview/<cap>/` prefix
38
+ * that everything else here works to preserve.
39
+ */
40
+ /** The same-origin path prefix an iframe loads for a given capability. */
41
+ export declare function proxyPrefixFor(capability: string): string;
42
+ /**
43
+ * Parse and validate a user-supplied dashboard URL.
44
+ *
45
+ * Rejects everything that is not plain `http:`/`https:`, anything carrying
46
+ * embedded credentials (they would be silently forwarded and logged), and
47
+ * anything without a hostname. Returns the normalized `URL` or null.
48
+ */
49
+ export declare function parseWebviewUrl(raw: string): URL | null;
50
+ /** Convenience predicate for Zod refinements. */
51
+ export declare function isValidWebviewUrl(raw: string): boolean;
52
+ /**
53
+ * Map a proxy request path to its upstream URL.
54
+ *
55
+ * `wildcard` is Fastify's `*` param: the path after `/webview/<cap>/`, without a
56
+ * leading slash. An empty wildcard means the landing page, which is the saved
57
+ * URL's own path and query.
58
+ *
59
+ * Returns null when the result would escape the upstream origin (a `..` chain, a
60
+ * protocol-relative `//evil.com` wildcard, or an absolute URL smuggled into the
61
+ * path). That check is what keeps this from being an open proxy.
62
+ */
63
+ export declare function resolveUpstreamUrl(savedUrl: string, wildcard: string, search: string): URL | null;
64
+ /** Extract the capability from a `/webview/<cap>/...` pathname, or null. */
65
+ export declare function capabilityFromProxyPath(pathname: string): string | null;
66
+ /**
67
+ * Extract the capability a `Referer` belongs to. Backs the 404 fallback that
68
+ * catches root-absolute asset requests (`/static/app.js`) which `<base>` cannot fix.
69
+ */
70
+ export declare function capabilityFromReferer(referer: string | undefined): string | null;
71
+ /** Remove the `frame-ancestors` directive from a CSP, preserving the rest. */
72
+ export declare function stripFrameAncestors(csp: string): string;
73
+ /** The `frame-ancestors` directive value from a CSP, or undefined. */
74
+ export declare function extractFrameAncestors(csp: string | undefined): string | undefined;
75
+ /**
76
+ * Whether a target permits being framed by a different origin, judged from its
77
+ * `X-Frame-Options` and CSP. Used only to recommend proxy vs direct mode in the
78
+ * editor; the proxy path works either way.
79
+ */
80
+ export declare function isFramableCrossOrigin(xFrameOptions: string | undefined, csp: string | undefined): boolean;
81
+ /**
82
+ * Rewrite an upstream `Location` into the proxy path.
83
+ *
84
+ * Same-origin redirects (relative or absolute) are remapped so the browser stays
85
+ * inside the frame. Cross-origin redirects are returned unchanged rather than
86
+ * proxied: relaying them would turn this into an open proxy for any host the
87
+ * upstream chooses to name.
88
+ */
89
+ export declare function rewriteLocation(location: string, requestUrl: URL, capability: string): string;
90
+ /**
91
+ * Rewrite an upstream `Set-Cookie` so it applies to the proxy path only.
92
+ *
93
+ * `Domain` is dropped (the cookie now belongs to Codeman's host), `Path` is
94
+ * rebased onto the proxy prefix so two dashboards cannot collide on a shared
95
+ * cookie name, and `Secure` is dropped when Codeman itself is serving plain HTTP
96
+ * in dev, where a Secure cookie would simply be discarded.
97
+ */
98
+ export declare function rewriteSetCookie(cookie: string, capability: string, secureContext: boolean): string;
99
+ /** Drop named cookies from a `Cookie` request header, returning undefined if none remain. */
100
+ export declare function filterCookieHeader(cookie: string | undefined, drop: string[]): string | undefined;
101
+ /** Build the header set sent upstream, from the browser's request headers. */
102
+ export declare function buildUpstreamRequestHeaders(incoming: Record<string, string | string[] | undefined>, upstream: URL, opts: {
103
+ forwardCookies: boolean;
104
+ sessionCookieName: string;
105
+ refererPath?: string;
106
+ }): Record<string, string>;
107
+ /**
108
+ * Build the response headers sent to the browser.
109
+ *
110
+ * Also returns the CSP to apply: the upstream's, minus `frame-ancestors`. Callers
111
+ * MUST set (or explicitly clear) this, because `registerSecurityHeaders` has
112
+ * already stamped Codeman's own `default-src 'self'` policy onto the reply, and
113
+ * that policy would break virtually every dashboard.
114
+ */
115
+ export declare function buildDownstreamResponseHeaders(upstreamHeaders: Iterable<[string, string]>,
116
+ /**
117
+ * Upstream `Set-Cookie` values, already separated. Passed in rather than read
118
+ * from `upstreamHeaders` because iterating a `Headers` object JOINS duplicate
119
+ * set-cookie values into one comma-separated string, which cannot be split back
120
+ * apart reliably (Expires dates contain commas). Callers use
121
+ * `response.headers.getSetCookie()`.
122
+ */
123
+ setCookies: string[], capability: string, requestUrl: URL, secureContext: boolean): {
124
+ headers: Record<string, string>;
125
+ setCookie: string[];
126
+ csp: string | null;
127
+ };
128
+ /**
129
+ * A tiny script injected at the top of every proxied document, rewriting
130
+ * ROOT-ABSOLUTE URLs built at runtime so they stay inside the proxy prefix.
131
+ *
132
+ * `<base href>` only governs URLs the HTML parser resolves. A dashboard that calls
133
+ * `fetch('/api/data')` bypasses it entirely and the request lands on Codeman's own
134
+ * root, where it 404s. That is not a rare shape: it is how most dashboards talk to
135
+ * their own backend, and it presents as the dashboard's own "Failed to fetch".
136
+ *
137
+ * The `Referer`-keyed 404 fallback catches some of these, but deliberately NOT
138
+ * paths under `/api`, `/ws` or `/q` (widening it there would let a request-supplied
139
+ * header skip auth on Codeman's own API). Rewriting inside the iframe removes the
140
+ * whole class instead of trading security for it: the page never emits a
141
+ * root-absolute request in the first place.
142
+ *
143
+ * Runs before any page script because it is injected immediately after `<base>`.
144
+ * Only same-origin, non-prefixed, root-absolute URLs are touched; relative URLs
145
+ * (already handled by `<base>`) and cross-origin URLs are passed through.
146
+ */
147
+ export declare function runtimeUrlShim(prefix: string): string;
148
+ /**
149
+ * Inject `<base href="/webview/<cap>/">` plus the runtime URL shim, and rebase
150
+ * root-absolute `src`/`href`/`action` attributes, which `<base>` alone does not
151
+ * affect.
152
+ *
153
+ * Three layers, because no single one is sufficient: `<base>` for parser-resolved
154
+ * relative URLs, attribute rewriting for root-absolute markup, and the shim for
155
+ * URLs built at runtime.
156
+ */
157
+ export declare function rewriteHtml(html: string, capability: string): string;
158
+ /**
159
+ * CORS headers for a proxied response.
160
+ *
161
+ * Non-obvious but load-bearing: a SANDBOXED iframe (no `allow-same-origin`) runs
162
+ * in an OPAQUE origin, so every `fetch`/XHR it makes is a cross-origin request even
163
+ * though the URL is on this very host, and the browser requires CORS headers to
164
+ * hand back the response. Without this, a dashboard renders fine (script/css/img
165
+ * loads are not CORS-checked) while every one of its API calls fails with an opaque
166
+ * `net::ERR_FAILED` and the page shows its own "failed to load" state. `curl`
167
+ * cannot reproduce it, because curl does not enforce CORS.
168
+ *
169
+ * The origin is echoed rather than `*` so credentialed requests still work in
170
+ * trusted mode. `null` (the opaque-origin case) is echoed as-is, but WITHOUT
171
+ * `allow-credentials`, which browsers reject in combination.
172
+ *
173
+ * This grants nothing extra: the URL is already gated by the capability, and only
174
+ * a document that was handed the capability can construct these requests.
175
+ */
176
+ export declare function buildProxyCorsHeaders(origin: string | undefined, requestedHeaders?: string): Record<string, string>;
177
+ /** Whether a content-type identifies HTML worth rewriting. */
178
+ export declare function isHtmlContentType(contentType: string | undefined): boolean;
179
+ /** Map an upstream http(s) URL to its ws(s) equivalent for the WebSocket leg. */
180
+ export declare function upstreamWebSocketUrl(target: URL): string;
181
+ //# sourceMappingURL=webview-proxy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webview-proxy.d.ts","sourceRoot":"","sources":["../../src/web/webview-proxy.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,491 @@
1
+ /**
2
+ * @fileoverview Pure helpers for the web-tab reverse proxy. No I/O, no Fastify.
3
+ *
4
+ * The proxy exists because an iframe pointing straight at a dashboard cannot work
5
+ * in the deployment that matters: prod serves HTTPS (behind `tailscale serve`), so
6
+ * a plain-HTTP dashboard is hard-blocked as mixed content; many dashboards also
7
+ * refuse framing outright via `X-Frame-Options` / `frame-ancestors`; and Codeman's
8
+ * own CSP (`default-src 'self'`) blocks cross-origin frames anyway. Serving the
9
+ * dashboard through Codeman's own origin dissolves all three at once, and keeps
10
+ * the production CSP byte-for-byte unchanged because `/webview/...` is `'self'`.
11
+ *
12
+ * ## Origin-scoped, not path-scoped
13
+ *
14
+ * `/webview/<cap>/x/y` always maps to `<upstream origin>/x/y`, never to
15
+ * `<upstream origin><saved path>/x/y`. Dashboards reference assets with
16
+ * root-absolute paths (`/public/build/app.js`), so origin-scoping is the only
17
+ * mapping under which those resolve. The saved URL's own path+query is used for
18
+ * exactly one thing: what `/webview/<cap>/` itself serves (the landing page).
19
+ *
20
+ * ## What gets rewritten, and why each one is load-bearing
21
+ *
22
+ * - `x-frame-options` / CSP `frame-ancestors`: dropped, else the browser refuses
23
+ * to render the frame. This is the whole point of the proxy.
24
+ * - `content-encoding` / `content-length`: dropped, because undici's `fetch`
25
+ * already decoded the body. Forwarding them makes the browser try to gunzip
26
+ * plaintext.
27
+ * - `authorization` + the `codeman_session` cookie: stripped on the way OUT. In
28
+ * trusted mode the iframe is same-origin, so the browser attaches Codeman's own
29
+ * Basic-auth header and session cookie to every proxied request. Forwarding
30
+ * those would hand CODEMAN_PASSWORD to the dashboard.
31
+ * - `Location` and `Set-Cookie`: remapped into the proxy path, else a redirect or
32
+ * a login cookie escapes the prefix and lands on Codeman's root.
33
+ * - `<base href>` + root-absolute attribute rewriting: relative and `/`-rooted
34
+ * URLs in the HTML resolve back through the proxy instead of hitting Codeman.
35
+ *
36
+ * No `X-Forwarded-*` is sent deliberately: apps that honor it generate absolute
37
+ * URLs against Codeman's root, which would bypass the `/webview/<cap>/` prefix
38
+ * that everything else here works to preserve.
39
+ */
40
+ import { WEBVIEW_PROXY_PREFIX } from '../config/webview-limits.js';
41
+ /** Headers that are per-connection and must never be relayed in either direction. */
42
+ const HOP_BY_HOP = new Set([
43
+ 'connection',
44
+ 'keep-alive',
45
+ 'proxy-authenticate',
46
+ 'proxy-authorization',
47
+ 'proxy-connection',
48
+ 'te',
49
+ 'trailer',
50
+ 'transfer-encoding',
51
+ 'upgrade',
52
+ ]);
53
+ /**
54
+ * Request headers dropped on the way to the upstream. `authorization` and `cookie`
55
+ * carry Codeman's own credentials on a same-origin (trusted) frame; `host`,
56
+ * `content-length` and `accept-encoding` are recomputed by undici.
57
+ */
58
+ const DROP_REQUEST_HEADERS = new Set([
59
+ ...HOP_BY_HOP,
60
+ 'host',
61
+ 'content-length',
62
+ 'accept-encoding',
63
+ 'authorization',
64
+ 'cookie',
65
+ 'origin',
66
+ 'referer',
67
+ 'x-codeman-hook-secret',
68
+ ]);
69
+ /** Response headers dropped on the way back to the browser. */
70
+ const DROP_RESPONSE_HEADERS = new Set([
71
+ ...HOP_BY_HOP,
72
+ 'content-encoding',
73
+ 'content-length',
74
+ 'x-frame-options',
75
+ 'content-security-policy-report-only',
76
+ // Cross-origin isolation headers describe the UPSTREAM's origin policy; applied
77
+ // to a frame on Codeman's origin they only produce blocked-resource surprises.
78
+ 'cross-origin-opener-policy',
79
+ 'cross-origin-embedder-policy',
80
+ 'cross-origin-resource-policy',
81
+ 'set-cookie',
82
+ 'location',
83
+ 'content-security-policy',
84
+ // The upstream's CORS answer describes ITS origin; the frame asking is
85
+ // opaque-origin on ours, so ours must replace it (see buildProxyCorsHeaders).
86
+ 'access-control-allow-origin',
87
+ 'access-control-allow-credentials',
88
+ 'access-control-allow-methods',
89
+ 'access-control-allow-headers',
90
+ 'access-control-expose-headers',
91
+ 'access-control-max-age',
92
+ ]);
93
+ /** The same-origin path prefix an iframe loads for a given capability. */
94
+ export function proxyPrefixFor(capability) {
95
+ return `${WEBVIEW_PROXY_PREFIX}/${capability}/`;
96
+ }
97
+ /**
98
+ * Parse and validate a user-supplied dashboard URL.
99
+ *
100
+ * Rejects everything that is not plain `http:`/`https:`, anything carrying
101
+ * embedded credentials (they would be silently forwarded and logged), and
102
+ * anything without a hostname. Returns the normalized `URL` or null.
103
+ */
104
+ export function parseWebviewUrl(raw) {
105
+ if (typeof raw !== 'string' || raw.trim() === '')
106
+ return null;
107
+ let url;
108
+ try {
109
+ url = new URL(raw.trim());
110
+ }
111
+ catch {
112
+ return null;
113
+ }
114
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
115
+ return null;
116
+ if (url.username !== '' || url.password !== '')
117
+ return null;
118
+ if (!url.hostname)
119
+ return null;
120
+ return url;
121
+ }
122
+ /** Convenience predicate for Zod refinements. */
123
+ export function isValidWebviewUrl(raw) {
124
+ return parseWebviewUrl(raw) !== null;
125
+ }
126
+ /**
127
+ * Map a proxy request path to its upstream URL.
128
+ *
129
+ * `wildcard` is Fastify's `*` param: the path after `/webview/<cap>/`, without a
130
+ * leading slash. An empty wildcard means the landing page, which is the saved
131
+ * URL's own path and query.
132
+ *
133
+ * Returns null when the result would escape the upstream origin (a `..` chain, a
134
+ * protocol-relative `//evil.com` wildcard, or an absolute URL smuggled into the
135
+ * path). That check is what keeps this from being an open proxy.
136
+ */
137
+ export function resolveUpstreamUrl(savedUrl, wildcard, search) {
138
+ const base = parseWebviewUrl(savedUrl);
139
+ if (!base)
140
+ return null;
141
+ if (wildcard === '' || wildcard === '/') {
142
+ const landing = new URL(base.pathname + (search || base.search), base.origin);
143
+ return landing.origin === base.origin ? landing : null;
144
+ }
145
+ // A wildcard starting with `//` would parse as protocol-relative and jump host.
146
+ const path = wildcard.startsWith('/') ? wildcard : `/${wildcard}`;
147
+ if (path.startsWith('//'))
148
+ return null;
149
+ let target;
150
+ try {
151
+ target = new URL(path + (search || ''), base.origin);
152
+ }
153
+ catch {
154
+ return null;
155
+ }
156
+ return target.origin === base.origin ? target : null;
157
+ }
158
+ /** Extract the capability from a `/webview/<cap>/...` pathname, or null. */
159
+ export function capabilityFromProxyPath(pathname) {
160
+ if (typeof pathname !== 'string')
161
+ return null;
162
+ const prefix = `${WEBVIEW_PROXY_PREFIX}/`;
163
+ if (!pathname.startsWith(prefix))
164
+ return null;
165
+ const rest = pathname.slice(prefix.length);
166
+ const slash = rest.indexOf('/');
167
+ const cap = slash === -1 ? rest : rest.slice(0, slash);
168
+ return /^[A-Za-z0-9_-]{16,128}$/.test(cap) ? cap : null;
169
+ }
170
+ /**
171
+ * Extract the capability a `Referer` belongs to. Backs the 404 fallback that
172
+ * catches root-absolute asset requests (`/static/app.js`) which `<base>` cannot fix.
173
+ */
174
+ export function capabilityFromReferer(referer) {
175
+ if (!referer)
176
+ return null;
177
+ try {
178
+ return capabilityFromProxyPath(new URL(referer).pathname);
179
+ }
180
+ catch {
181
+ return null;
182
+ }
183
+ }
184
+ /** Remove the `frame-ancestors` directive from a CSP, preserving the rest. */
185
+ export function stripFrameAncestors(csp) {
186
+ return csp
187
+ .split(';')
188
+ .map((d) => d.trim())
189
+ .filter((d) => d !== '' && !/^frame-ancestors\b/i.test(d))
190
+ .join('; ');
191
+ }
192
+ /** The `frame-ancestors` directive value from a CSP, or undefined. */
193
+ export function extractFrameAncestors(csp) {
194
+ if (!csp)
195
+ return undefined;
196
+ for (const directive of csp.split(';')) {
197
+ const trimmed = directive.trim();
198
+ if (/^frame-ancestors\b/i.test(trimmed)) {
199
+ return trimmed.slice('frame-ancestors'.length).trim();
200
+ }
201
+ }
202
+ return undefined;
203
+ }
204
+ /**
205
+ * Whether a target permits being framed by a different origin, judged from its
206
+ * `X-Frame-Options` and CSP. Used only to recommend proxy vs direct mode in the
207
+ * editor; the proxy path works either way.
208
+ */
209
+ export function isFramableCrossOrigin(xFrameOptions, csp) {
210
+ const xfo = xFrameOptions?.trim().toLowerCase();
211
+ if (xfo === 'deny' || xfo === 'sameorigin')
212
+ return false;
213
+ const ancestors = extractFrameAncestors(csp)?.toLowerCase();
214
+ if (ancestors === undefined)
215
+ return true;
216
+ if (ancestors.includes("'none'"))
217
+ return false;
218
+ // 'self' alone means same-origin only, which a cross-origin embed is not.
219
+ if (ancestors === "'self'")
220
+ return false;
221
+ return ancestors.includes('*') || ancestors.includes('http');
222
+ }
223
+ /**
224
+ * Rewrite an upstream `Location` into the proxy path.
225
+ *
226
+ * Same-origin redirects (relative or absolute) are remapped so the browser stays
227
+ * inside the frame. Cross-origin redirects are returned unchanged rather than
228
+ * proxied: relaying them would turn this into an open proxy for any host the
229
+ * upstream chooses to name.
230
+ */
231
+ export function rewriteLocation(location, requestUrl, capability) {
232
+ let resolved;
233
+ try {
234
+ resolved = new URL(location, requestUrl);
235
+ }
236
+ catch {
237
+ return location;
238
+ }
239
+ if (resolved.origin !== requestUrl.origin)
240
+ return location;
241
+ const suffix = resolved.pathname.replace(/^\//, '');
242
+ return `${proxyPrefixFor(capability)}${suffix}${resolved.search}${resolved.hash}`;
243
+ }
244
+ /**
245
+ * Rewrite an upstream `Set-Cookie` so it applies to the proxy path only.
246
+ *
247
+ * `Domain` is dropped (the cookie now belongs to Codeman's host), `Path` is
248
+ * rebased onto the proxy prefix so two dashboards cannot collide on a shared
249
+ * cookie name, and `Secure` is dropped when Codeman itself is serving plain HTTP
250
+ * in dev, where a Secure cookie would simply be discarded.
251
+ */
252
+ export function rewriteSetCookie(cookie, capability, secureContext) {
253
+ const parts = cookie.split(';');
254
+ const out = [parts[0]];
255
+ let sawPath = false;
256
+ for (const raw of parts.slice(1)) {
257
+ const attr = raw.trim();
258
+ const lower = attr.toLowerCase();
259
+ if (lower.startsWith('domain='))
260
+ continue;
261
+ if (lower === 'secure' && !secureContext)
262
+ continue;
263
+ if (lower.startsWith('path=')) {
264
+ sawPath = true;
265
+ const value = attr.slice('path='.length);
266
+ const suffix = value.replace(/^\//, '');
267
+ out.push(`Path=${proxyPrefixFor(capability)}${suffix}`);
268
+ continue;
269
+ }
270
+ out.push(attr);
271
+ }
272
+ if (!sawPath)
273
+ out.push(`Path=${proxyPrefixFor(capability)}`);
274
+ return out.join('; ');
275
+ }
276
+ /** Drop named cookies from a `Cookie` request header, returning undefined if none remain. */
277
+ export function filterCookieHeader(cookie, drop) {
278
+ if (!cookie)
279
+ return undefined;
280
+ const dropSet = new Set(drop.map((n) => n.toLowerCase()));
281
+ const kept = cookie
282
+ .split(';')
283
+ .map((c) => c.trim())
284
+ .filter((c) => c !== '' && !dropSet.has(c.slice(0, c.indexOf('=')).trim().toLowerCase()));
285
+ return kept.length > 0 ? kept.join('; ') : undefined;
286
+ }
287
+ /** Build the header set sent upstream, from the browser's request headers. */
288
+ export function buildUpstreamRequestHeaders(incoming, upstream, opts) {
289
+ const headers = {};
290
+ for (const [key, value] of Object.entries(incoming)) {
291
+ const lower = key.toLowerCase();
292
+ if (DROP_REQUEST_HEADERS.has(lower))
293
+ continue;
294
+ if (value === undefined)
295
+ continue;
296
+ headers[lower] = Array.isArray(value) ? value.join(', ') : value;
297
+ }
298
+ if (opts.forwardCookies) {
299
+ const raw = incoming['cookie'];
300
+ const cookie = filterCookieHeader(Array.isArray(raw) ? raw.join('; ') : raw, [opts.sessionCookieName]);
301
+ if (cookie)
302
+ headers['cookie'] = cookie;
303
+ }
304
+ // Present as if the browser were talking to the dashboard directly. Apps that
305
+ // check Origin on writes (CSRF defenses) need this to match their own origin.
306
+ headers['origin'] = upstream.origin;
307
+ headers['referer'] = opts.refererPath ? new URL(opts.refererPath, upstream.origin).href : upstream.href;
308
+ return headers;
309
+ }
310
+ /**
311
+ * Build the response headers sent to the browser.
312
+ *
313
+ * Also returns the CSP to apply: the upstream's, minus `frame-ancestors`. Callers
314
+ * MUST set (or explicitly clear) this, because `registerSecurityHeaders` has
315
+ * already stamped Codeman's own `default-src 'self'` policy onto the reply, and
316
+ * that policy would break virtually every dashboard.
317
+ */
318
+ export function buildDownstreamResponseHeaders(upstreamHeaders,
319
+ /**
320
+ * Upstream `Set-Cookie` values, already separated. Passed in rather than read
321
+ * from `upstreamHeaders` because iterating a `Headers` object JOINS duplicate
322
+ * set-cookie values into one comma-separated string, which cannot be split back
323
+ * apart reliably (Expires dates contain commas). Callers use
324
+ * `response.headers.getSetCookie()`.
325
+ */
326
+ setCookies, capability, requestUrl, secureContext) {
327
+ const headers = {};
328
+ let csp = null;
329
+ for (const [key, value] of upstreamHeaders) {
330
+ const lower = key.toLowerCase();
331
+ if (lower === 'content-security-policy') {
332
+ const stripped = stripFrameAncestors(value);
333
+ csp = stripped === '' ? null : stripped;
334
+ continue;
335
+ }
336
+ if (lower === 'location') {
337
+ headers['location'] = rewriteLocation(value, requestUrl, capability);
338
+ continue;
339
+ }
340
+ if (DROP_RESPONSE_HEADERS.has(lower))
341
+ continue;
342
+ headers[lower] = value;
343
+ }
344
+ const setCookie = setCookies.map((cookie) => rewriteSetCookie(cookie, capability, secureContext));
345
+ return { headers, setCookie, csp };
346
+ }
347
+ /**
348
+ * A tiny script injected at the top of every proxied document, rewriting
349
+ * ROOT-ABSOLUTE URLs built at runtime so they stay inside the proxy prefix.
350
+ *
351
+ * `<base href>` only governs URLs the HTML parser resolves. A dashboard that calls
352
+ * `fetch('/api/data')` bypasses it entirely and the request lands on Codeman's own
353
+ * root, where it 404s. That is not a rare shape: it is how most dashboards talk to
354
+ * their own backend, and it presents as the dashboard's own "Failed to fetch".
355
+ *
356
+ * The `Referer`-keyed 404 fallback catches some of these, but deliberately NOT
357
+ * paths under `/api`, `/ws` or `/q` (widening it there would let a request-supplied
358
+ * header skip auth on Codeman's own API). Rewriting inside the iframe removes the
359
+ * whole class instead of trading security for it: the page never emits a
360
+ * root-absolute request in the first place.
361
+ *
362
+ * Runs before any page script because it is injected immediately after `<base>`.
363
+ * Only same-origin, non-prefixed, root-absolute URLs are touched; relative URLs
364
+ * (already handled by `<base>`) and cross-origin URLs are passed through.
365
+ */
366
+ export function runtimeUrlShim(prefix) {
367
+ // Kept dependency-free and defensive: it runs inside a page we do not control,
368
+ // and a throw here would break the dashboard rather than fix it.
369
+ return `<script>(function(){try{
370
+ var P=${JSON.stringify(prefix)};
371
+ function rw(u){
372
+ try{
373
+ if(u==null)return u;
374
+ if(typeof u!=='string'){
375
+ if(typeof URL!=='undefined'&&u instanceof URL)return rw(u.href);
376
+ return u;
377
+ }
378
+ if(u.indexOf(P)===0)return u;
379
+ if(u.charAt(0)==='/'&&u.charAt(1)!=='/')return P+u.slice(1);
380
+ if(/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(u)||u.indexOf('//')===0){
381
+ var a=new URL(u,location.href);
382
+ if(a.host===location.host&&a.pathname.indexOf(P)!==0){
383
+ a.pathname=P+a.pathname.replace(/^\\//,'');
384
+ return a.href;
385
+ }
386
+ }
387
+ return u;
388
+ }catch(e){return u;}
389
+ }
390
+ var of=window.fetch;
391
+ if(of)window.fetch=function(i,o){
392
+ try{
393
+ if(typeof Request!=='undefined'&&i instanceof Request)return of.call(this,new Request(rw(i.url),i),o);
394
+ return of.call(this,rw(i),o);
395
+ }catch(e){return of.call(this,i,o);}
396
+ };
397
+ if(window.XMLHttpRequest&&XMLHttpRequest.prototype.open){
398
+ var oo=XMLHttpRequest.prototype.open;
399
+ XMLHttpRequest.prototype.open=function(m,u){
400
+ var a=[].slice.call(arguments);a[1]=rw(u);return oo.apply(this,a);
401
+ };
402
+ }
403
+ ['WebSocket','EventSource'].forEach(function(k){
404
+ var C=window[k];if(!C)return;
405
+ function W(u,p){return p===undefined?new C(rw(u)):new C(rw(u),p);}
406
+ W.prototype=C.prototype;
407
+ ['CONNECTING','OPEN','CLOSING','CLOSED'].forEach(function(s){if(s in C)W[s]=C[s];});
408
+ window[k]=W;
409
+ });
410
+ }catch(e){}})();</script>`;
411
+ }
412
+ /**
413
+ * Inject `<base href="/webview/<cap>/">` plus the runtime URL shim, and rebase
414
+ * root-absolute `src`/`href`/`action` attributes, which `<base>` alone does not
415
+ * affect.
416
+ *
417
+ * Three layers, because no single one is sufficient: `<base>` for parser-resolved
418
+ * relative URLs, attribute rewriting for root-absolute markup, and the shim for
419
+ * URLs built at runtime.
420
+ */
421
+ export function rewriteHtml(html, capability) {
422
+ const prefix = proxyPrefixFor(capability);
423
+ // Fresh regexes per call: module-level /g patterns carry `lastIndex` between calls.
424
+ const rebased = html
425
+ .replace(/(\s(?:src|href|action)\s*=\s*")\/(?!\/)/gi, `$1${prefix}`)
426
+ .replace(/(\s(?:src|href|action)\s*=\s*')\/(?!\/)/gi, `$1${prefix}`);
427
+ // A page that ships its own <base> keeps it (overriding it would break the
428
+ // author's intent), but it STILL needs the shim, which is the layer that
429
+ // catches runtime-built URLs. So only the base tag is conditional.
430
+ const injected = (/<base\b/i.test(rebased) ? '' : `<base href="${prefix}">`) + runtimeUrlShim(prefix);
431
+ const headMatch = /<head\b[^>]*>/i.exec(rebased);
432
+ if (headMatch) {
433
+ const at = headMatch.index + headMatch[0].length;
434
+ return rebased.slice(0, at) + injected + rebased.slice(at);
435
+ }
436
+ const htmlMatch = /<html\b[^>]*>/i.exec(rebased);
437
+ if (htmlMatch) {
438
+ const at = htmlMatch.index + htmlMatch[0].length;
439
+ return rebased.slice(0, at) + injected + rebased.slice(at);
440
+ }
441
+ return injected + rebased;
442
+ }
443
+ /**
444
+ * CORS headers for a proxied response.
445
+ *
446
+ * Non-obvious but load-bearing: a SANDBOXED iframe (no `allow-same-origin`) runs
447
+ * in an OPAQUE origin, so every `fetch`/XHR it makes is a cross-origin request even
448
+ * though the URL is on this very host, and the browser requires CORS headers to
449
+ * hand back the response. Without this, a dashboard renders fine (script/css/img
450
+ * loads are not CORS-checked) while every one of its API calls fails with an opaque
451
+ * `net::ERR_FAILED` and the page shows its own "failed to load" state. `curl`
452
+ * cannot reproduce it, because curl does not enforce CORS.
453
+ *
454
+ * The origin is echoed rather than `*` so credentialed requests still work in
455
+ * trusted mode. `null` (the opaque-origin case) is echoed as-is, but WITHOUT
456
+ * `allow-credentials`, which browsers reject in combination.
457
+ *
458
+ * This grants nothing extra: the URL is already gated by the capability, and only
459
+ * a document that was handed the capability can construct these requests.
460
+ */
461
+ export function buildProxyCorsHeaders(origin, requestedHeaders) {
462
+ if (!origin)
463
+ return {};
464
+ const headers = {
465
+ 'access-control-allow-origin': origin,
466
+ 'access-control-allow-methods': 'GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS',
467
+ 'access-control-allow-headers': requestedHeaders && requestedHeaders.trim() !== '' ? requestedHeaders : '*',
468
+ 'access-control-expose-headers': '*',
469
+ 'access-control-max-age': '600',
470
+ vary: 'Origin',
471
+ };
472
+ // `Access-Control-Allow-Credentials: true` alongside a `null` origin is rejected
473
+ // by browsers; a sandboxed frame sends no credentials anyway.
474
+ if (origin !== 'null' && origin !== '*')
475
+ headers['access-control-allow-credentials'] = 'true';
476
+ return headers;
477
+ }
478
+ /** Whether a content-type identifies HTML worth rewriting. */
479
+ export function isHtmlContentType(contentType) {
480
+ if (!contentType)
481
+ return false;
482
+ const type = contentType.split(';')[0].trim().toLowerCase();
483
+ return type === 'text/html' || type === 'application/xhtml+xml';
484
+ }
485
+ /** Map an upstream http(s) URL to its ws(s) equivalent for the WebSocket leg. */
486
+ export function upstreamWebSocketUrl(target) {
487
+ const ws = new URL(target.href);
488
+ ws.protocol = ws.protocol === 'https:' ? 'wss:' : 'ws:';
489
+ return ws.href;
490
+ }
491
+ //# sourceMappingURL=webview-proxy.js.map