sliccy 4.23.0 → 4.25.0

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 (97) hide show
  1. package/dist/node-server/bridge-security.d.ts +56 -2
  2. package/dist/node-server/bridge-security.js +186 -9
  3. package/dist/node-server/fetch-proxy-headers.d.ts +8 -2
  4. package/dist/node-server/fetch-proxy-headers.js +9 -2
  5. package/dist/node-server/index.js +153 -45
  6. package/dist/node-server/routes/fetch-proxy.d.ts +8 -0
  7. package/dist/node-server/routes/fetch-proxy.js +6 -1
  8. package/dist/ui/assets/{account-store-9y5Ab4vV.js → account-store-CCx84KSO.js} +2 -2
  9. package/dist/ui/assets/{account-store-CSLAx3pa.js → account-store-D6F5ht1K.js} +2 -2
  10. package/dist/ui/assets/adobe-B_gJbGrM.js +1 -0
  11. package/dist/ui/assets/adobe-D8jV1ibX.js +2 -0
  12. package/dist/ui/assets/{agent-message-to-chat-bEHX4iF4.js → agent-message-to-chat-Dt9iSVuQ.js} +1 -1
  13. package/dist/ui/assets/{apps-Dh-H9LYE.js → apps-CyKH98UU.js} +1 -1
  14. package/dist/ui/assets/{azure-openai-BpIYfrM-.js → azure-openai-CTCrw8ag.js} +1 -1
  15. package/dist/ui/assets/{azure-openai-C4YFzA3o.js → azure-openai-DHxbdJP2.js} +1 -1
  16. package/dist/ui/assets/{bsh-watchdog-CNmrTRAy.js → bsh-watchdog-DRf6YtLF.js} +1 -1
  17. package/dist/ui/assets/{cdp-BGgdiko1.js → cdp-D4JkCTey.js} +2 -2
  18. package/dist/ui/assets/{connect-surface-DTsPWNDo.js → connect-surface-BAkLQjYg.js} +1 -1
  19. package/dist/ui/assets/dip-CoQDOdRF.js +1 -0
  20. package/dist/ui/assets/{dist-CjQIX13Y.js → dist-DstXLyS2.js} +1 -1
  21. package/dist/ui/assets/{dist-B5wGuNlW.js → dist-Gjxc3CGe.js} +1 -1
  22. package/dist/ui/assets/{es-t4MB1uY0.js → es-BtePr83c.js} +1 -1
  23. package/dist/ui/assets/{fs-ry06ziaM.js → fs-CES5lb4o.js} +1 -1
  24. package/dist/ui/assets/{fs-BlhR8i_q.js → fs-D3A8iUb-.js} +2 -2
  25. package/dist/ui/assets/{github-Cr1bhcIq.js → github-BD9mji3m.js} +1 -1
  26. package/dist/ui/assets/{github-6R3ONWs4.js → github-BjJwZAID.js} +2 -2
  27. package/dist/ui/assets/{github-copilot-_vAY24oI.js → github-copilot-O5-ryiZR.js} +1 -1
  28. package/dist/ui/assets/{github-copilot-CPpSIAl_.js → github-copilot-yWe1M0K_.js} +1 -1
  29. package/dist/ui/assets/{hear-DLqP08Yk.js → hear-CFjk8o5A.js} +1 -1
  30. package/dist/ui/assets/{kernel-worker-ReGrzvdD.js → kernel-worker-DS4Sjo4b.js} +1029 -1029
  31. package/dist/ui/assets/{kokoro-engine-BeiKy8PB.js → kokoro-engine-TLc0ityO.js} +1 -1
  32. package/dist/ui/assets/{lick-ws-bridge-DZy9C70j.js → lick-ws-bridge-Fa3rBD7K.js} +1 -1
  33. package/dist/ui/assets/{local-llm-ehhQ9AeC.js → local-llm-77SlLHlX.js} +1 -1
  34. package/dist/ui/assets/main-RvuAuHo8.js +24 -0
  35. package/dist/ui/assets/{main-cherry-X1IxoqNY.js → main-cherry-B1mI8ArD.js} +1 -1
  36. package/dist/ui/assets/{mount-Crd-I-lS.js → mount-B3baMU-O.js} +1 -1
  37. package/dist/ui/assets/{mount-Cf2e3Vu8.js → mount-Dwoznret.js} +2 -2
  38. package/dist/ui/assets/{new-session-PJxD5xz-.js → new-session-BnW6Ia6h.js} +2 -2
  39. package/dist/ui/assets/{oauth-bootstrap-38UC9ABk.js → oauth-bootstrap-D4jfK89n.js} +2 -2
  40. package/dist/ui/assets/oauth-service-CoJ-pYVD.js +1 -0
  41. package/dist/ui/assets/oauth-service-hTyvduF1.js +1 -0
  42. package/dist/ui/assets/{openai-codex-CcWEg2Id.js → openai-codex-BG86bd3g.js} +1 -1
  43. package/dist/ui/assets/{openai-codex-4vY4IfjZ.js → openai-codex-W1k-olNJ.js} +1 -1
  44. package/dist/ui/assets/{panel-rpc-handlers-CyLUoTYQ.js → panel-rpc-handlers-BAwdZnUG.js} +1 -1
  45. package/dist/ui/assets/{provider-BRZxUH2o.js → provider-Ba8_qOfx.js} +1 -1
  46. package/dist/ui/assets/{provider-BGI7Ytuv.js → provider-fDZ2ppW0.js} +2 -2
  47. package/dist/ui/assets/provider-store-access-BVVboGQW.js +1 -0
  48. package/dist/ui/assets/provider-store-access-DLLL3FmZ.js +1 -0
  49. package/dist/ui/assets/{providers-CcDiXx1v.js → providers-DteOckdy.js} +1 -1
  50. package/dist/ui/assets/proxied-fetch-BBO8HwcV.js +1 -0
  51. package/dist/ui/assets/{quick-llm-CpcpayIL.js → quick-llm-BRbz1fDI.js} +1 -1
  52. package/dist/ui/assets/{remote-terminal-view-FVcjc7kw.js → remote-terminal-view-CSqiig2G.js} +1 -1
  53. package/dist/ui/assets/session-freezer-tVkiu4V5.js +1 -0
  54. package/dist/ui/assets/{setup-sprinkle-exec-BGXT8k4k.js → setup-sprinkle-exec-C7SBZY7y.js} +1 -1
  55. package/dist/ui/assets/setup-sudo-DWfiGlOf.js +1 -0
  56. package/dist/ui/assets/{speak-gvgsbAbr.js → speak-DhkLZ-am.js} +1 -1
  57. package/dist/ui/assets/{sprinkle-manager-am-b2nZK.js → sprinkle-manager-BLhwuVdT.js} +1 -1
  58. package/dist/ui/assets/{store-2Y1B359N.js → store-T-K8TeFw.js} +1 -1
  59. package/dist/ui/assets/{sudo-CXuD9JQi.js → sudo-qsDyNaAG.js} +1 -1
  60. package/dist/ui/assets/terminal-session-client-CbAduNfC.js +1 -0
  61. package/dist/ui/assets/{transformers-env-RCjmw9ix.js → transformers-env-DSAN95G3.js} +1 -1
  62. package/dist/ui/assets/{tray-leave-runtime-EjSnz0hB.js → tray-leave-runtime-tqIb9yl_.js} +1 -1
  63. package/dist/ui/assets/upgrade-detection-pjQXVRWP.js +1 -0
  64. package/dist/ui/assets/{wc-attach-BbBcc9d7.js → wc-attach-DwcZ5MaG.js} +2 -2
  65. package/dist/ui/assets/{wc-detached-BsGgVyQ2.js → wc-detached-BvZo_AId.js} +1 -1
  66. package/dist/ui/assets/{wc-extension-pjc1MPFN.js → wc-extension-CtHl8VdG.js} +2 -2
  67. package/dist/ui/assets/wc-live-BHTqp_8F.js +253 -0
  68. package/dist/ui/assets/{wc-nav-D1GUd0Mt.js → wc-nav-BJuQ2irW.js} +2 -2
  69. package/dist/ui/assets/wc-onboarding-84S404WJ.js +2 -0
  70. package/dist/ui/assets/{wc-placeholder-CEMVuHe3.js → wc-placeholder-DXEcgvyw.js} +3 -3
  71. package/dist/ui/assets/wc-settings-DWuDKTDp.js +24 -0
  72. package/dist/ui/assets/{wc-shell-tnKegXuB.js → wc-shell-B_z0VSKk.js} +3 -3
  73. package/dist/ui/assets/{wc-sprinkles-DEgP8Ihj.js → wc-sprinkles-BsYJVGg_.js} +2 -2
  74. package/dist/ui/assets/{wc-tray-BsL3c-FP.js → wc-tray-CWJphCmt.js} +3 -3
  75. package/dist/ui/assets/{xai-grok-BK-WZwtZ.js → xai-grok-D7ULF1Ef.js} +1 -1
  76. package/dist/ui/assets/{xai-grok-CqrCTXgB.js → xai-grok-DQ32cJt0.js} +1 -1
  77. package/dist/ui/index.html +2 -2
  78. package/dist/ui/llm-proxy-sw.js +1 -1
  79. package/dist/ui/packages/webapp/index.html +2 -2
  80. package/package.json +1 -1
  81. package/dist/ui/assets/adobe-Ba8U6Soe.js +0 -2
  82. package/dist/ui/assets/adobe-BtvdR5I0.js +0 -1
  83. package/dist/ui/assets/dip-Fvfzs0-q.js +0 -1
  84. package/dist/ui/assets/main-zx9b4e1t.js +0 -24
  85. package/dist/ui/assets/oauth-service-CTlktxVj.js +0 -1
  86. package/dist/ui/assets/oauth-service-DQVciyk_.js +0 -1
  87. package/dist/ui/assets/provider-store-access-BiJN5bAM.js +0 -1
  88. package/dist/ui/assets/provider-store-access-Dp0MGqAc.js +0 -1
  89. package/dist/ui/assets/proxied-fetch-Dg114MVx.js +0 -1
  90. package/dist/ui/assets/proxy-error-9UvXMpzG.js +0 -1
  91. package/dist/ui/assets/session-freezer-DQ--9MwT.js +0 -1
  92. package/dist/ui/assets/setup-sudo-I4S39zW5.js +0 -1
  93. package/dist/ui/assets/terminal-session-client-C_tYJ1IB.js +0 -1
  94. package/dist/ui/assets/upgrade-detection-CHScEQnq.js +0 -1
  95. package/dist/ui/assets/wc-live-BAWrPhr9.js +0 -253
  96. package/dist/ui/assets/wc-onboarding-Bt02HM9-.js +0 -2
  97. package/dist/ui/assets/wc-settings-DP3TVlWk.js +0 -24
@@ -23,6 +23,12 @@
23
23
  * origins (parallel to chrome-extension's `BRIDGE_DEV_ORIGINS`). Add a new
24
24
  * origin here and in the extension allowlist together — they MUST stay in
25
25
  * sync, otherwise extension and standalone disagree on what's a leader.
26
+ *
27
+ * Dev-only extra origins can be added at process start via the
28
+ * `BRIDGE_DEV_ALLOWED_ORIGINS` env var (comma-separated). Used by the
29
+ * local two-service harness (wrangler dev UI on :8787 + node-server
30
+ * bridge); see Wave 5c. When the env var is unset, the effective
31
+ * allowlist is byte-identical to this frozen base — prod is unaffected.
26
32
  */
27
33
  export declare const BRIDGE_ALLOWED_ORIGINS: readonly string[];
28
34
  /** Subprotocol prefix advertised by the leader; the per-process token is appended. */
@@ -31,8 +37,40 @@ export declare const BRIDGE_SUBPROTOCOL_PREFIX = "slicc.bridge.v1.";
31
37
  export declare const BRIDGE_TOKEN_QUERY_PARAM = "bridgeToken";
32
38
  /** Query-param name the launch URL uses to forward the local /cdp WebSocket URL. */
33
39
  export declare const BRIDGE_WS_QUERY_PARAM = "bridge";
34
- /** True iff `origin` is in the bridge allowlist. Case-sensitive (origins are normalized lowercase). */
40
+ /**
41
+ * Request header carrying the per-process bridge token on cross-origin
42
+ * /api/* calls from a remote allowlisted leader (e.g. sliccy.ai). The
43
+ * webapp's `proxied-fetch.ts` attaches it whenever a local API base
44
+ * origin is set; the thin-bridge CORS middleware validates it. Header
45
+ * is included in `CORS_BASE_ALLOW_HEADERS` so browsers don't strip it
46
+ * on the preflight.
47
+ */
48
+ export declare const BRIDGE_TOKEN_HEADER = "X-Bridge-Token";
49
+ /**
50
+ * True iff `origin` is in the bridge allowlist. The frozen prod base
51
+ * (`BRIDGE_ALLOWED_ORIGINS`) is matched case-sensitively against the
52
+ * raw origin; the dev-only env-supplied extras (normalized lowercase
53
+ * + trailing-slash-stripped at load) are matched against a normalized
54
+ * copy of the input, so a leader sending `Origin: HTTP://Localhost:8787`
55
+ * (or with a stray trailing slash) still resolves correctly.
56
+ */
35
57
  export declare function isAllowedBridgeOrigin(origin: string | undefined | null): boolean;
58
+ /**
59
+ * True iff `origin` is a loopback host (localhost / 127.0.0.1 / ::1).
60
+ * Loopback allowlisted origins (e.g. the locally-served OAuth callback
61
+ * page at `http://localhost:5710/auth/callback` posting to
62
+ * `/api/oauth-result`) are exempt from the bridge-token requirement —
63
+ * the token's threat model is "remote allowlisted origin (sliccy.ai)
64
+ * with a hostile script", not "local server talking to itself".
65
+ */
66
+ export declare function isLoopbackBridgeOrigin(origin: string | undefined | null): boolean;
67
+ /**
68
+ * Constant-time compare for the bridge token. `presented` may be missing
69
+ * or shaped wrong (Express delivers headers as `string | string[] |
70
+ * undefined`). Returns `false` for any non-string, length mismatch, or
71
+ * empty expected — never throws.
72
+ */
73
+ export declare function validateBridgeToken(presented: string | string[] | undefined, expected: string | null): boolean;
36
74
  /**
37
75
  * Mint a per-process bridge token. Embedded in the leader launch URL and
38
76
  * required as the WebSocket subprotocol on /cdp. Use `crypto.randomUUID()`
@@ -77,6 +115,18 @@ export declare function validateBridgeUpgrade(input: {
77
115
  subprotocolHeader: string | string[] | undefined;
78
116
  expectedToken: string;
79
117
  }): BridgeUpgradeGateResult;
118
+ /**
119
+ * Resolve the `Access-Control-Allow-Headers` value for a request. Starts
120
+ * from `CORS_BASE_ALLOW_HEADERS` (the static set covering the documented
121
+ * /api endpoints + the `/api/fetch-proxy` transport headers) and unions
122
+ * in any header names from the request's `Access-Control-Request-Headers`
123
+ * that aren't already listed. This is the reflect-headers pattern: the
124
+ * agent's `bash curl -H X-Custom: …` can route through `/api/fetch-proxy`
125
+ * cross-origin without us having to enumerate every possible upstream
126
+ * header in advance. Comparison is case-insensitive; the static set's
127
+ * canonical casing wins on duplicates.
128
+ */
129
+ export declare function resolveCorsAllowHeaders(requestHeadersHeader: string | undefined | null): string;
80
130
  /**
81
131
  * CORS headers for an allowlisted `Origin`. Returns `null` when the origin
82
132
  * is not in the allowlist (caller should NOT set CORS headers).
@@ -84,8 +134,12 @@ export declare function validateBridgeUpgrade(input: {
84
134
  * `Access-Control-Allow-Credentials: true` is included so the hosted leader
85
135
  * can carry cookies to /api/* (e.g. auth) when that's added later. Today
86
136
  * the bridge token is the auth factor, not cookies.
137
+ *
138
+ * `requestHeadersHeader` should be the request's `Access-Control-Request-Headers`
139
+ * value (preflight only); on a non-preflight request pass `null` and the
140
+ * caller can omit it.
87
141
  */
88
- export declare function buildCorsHeaders(origin: string | undefined | null): Record<string, string> | null;
142
+ export declare function buildCorsHeaders(origin: string | undefined | null, requestHeadersHeader?: string | string[] | null): Record<string, string> | null;
89
143
  /**
90
144
  * PNA preflight extras. Added on OPTIONS responses for allowlisted origins
91
145
  * when the request carries `Access-Control-Request-Private-Network: true`
@@ -18,12 +18,18 @@
18
18
  * Pure module (no node-only imports) so the WS gate is trivially unit
19
19
  * testable from `tests/bridge-security.test.ts`.
20
20
  */
21
- import { randomUUID } from 'node:crypto';
21
+ import { randomUUID, timingSafeEqual } from 'node:crypto';
22
22
  /**
23
23
  * Origin allowlist. Production + staging worker plus the dev-mode loopback
24
24
  * origins (parallel to chrome-extension's `BRIDGE_DEV_ORIGINS`). Add a new
25
25
  * origin here and in the extension allowlist together — they MUST stay in
26
26
  * sync, otherwise extension and standalone disagree on what's a leader.
27
+ *
28
+ * Dev-only extra origins can be added at process start via the
29
+ * `BRIDGE_DEV_ALLOWED_ORIGINS` env var (comma-separated). Used by the
30
+ * local two-service harness (wrangler dev UI on :8787 + node-server
31
+ * bridge); see Wave 5c. When the env var is unset, the effective
32
+ * allowlist is byte-identical to this frozen base — prod is unaffected.
27
33
  */
28
34
  export const BRIDGE_ALLOWED_ORIGINS = Object.freeze([
29
35
  'https://www.sliccy.ai',
@@ -31,21 +37,155 @@ export const BRIDGE_ALLOWED_ORIGINS = Object.freeze([
31
37
  'http://localhost:5710',
32
38
  'http://127.0.0.1:5710',
33
39
  ]);
40
+ /**
41
+ * Normalize a single env-supplied origin: trim, lowercase, drop trailing
42
+ * slash. Returns `null` for blank/whitespace entries or anything that
43
+ * `URL` can't parse. Never throws.
44
+ */
45
+ function normalizeDevOrigin(raw) {
46
+ const trimmed = raw.trim();
47
+ if (!trimmed)
48
+ return null;
49
+ let candidate = trimmed.toLowerCase();
50
+ while (candidate.endsWith('/')) {
51
+ candidate = candidate.slice(0, -1);
52
+ }
53
+ if (!candidate)
54
+ return null;
55
+ try {
56
+ // Sanity check: must parse as an absolute URL with scheme + host.
57
+ const parsed = new URL(candidate);
58
+ if (!parsed.protocol || !parsed.hostname)
59
+ return null;
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ return candidate;
65
+ }
66
+ /**
67
+ * Dev-only extra origins parsed once from `BRIDGE_DEV_ALLOWED_ORIGINS` at
68
+ * module load. Comma-separated; blank entries ignored; malformed entries
69
+ * dropped. Frozen prod base above is left intact; `isAllowedBridgeOrigin`
70
+ * consults the union.
71
+ */
72
+ const BRIDGE_DEV_ALLOWED_ORIGINS = (() => {
73
+ const raw = process.env.BRIDGE_DEV_ALLOWED_ORIGINS;
74
+ if (!raw)
75
+ return new Set();
76
+ const set = new Set();
77
+ for (const entry of raw.split(',')) {
78
+ const normalized = normalizeDevOrigin(entry);
79
+ if (normalized)
80
+ set.add(normalized);
81
+ }
82
+ return set;
83
+ })();
34
84
  /** Subprotocol prefix advertised by the leader; the per-process token is appended. */
35
85
  export const BRIDGE_SUBPROTOCOL_PREFIX = 'slicc.bridge.v1.';
36
86
  /** Query-param name the launch URL uses to forward the subprotocol token to the leader. */
37
87
  export const BRIDGE_TOKEN_QUERY_PARAM = 'bridgeToken';
38
88
  /** Query-param name the launch URL uses to forward the local /cdp WebSocket URL. */
39
89
  export const BRIDGE_WS_QUERY_PARAM = 'bridge';
40
- /** Headers we allow on cross-origin /api requests from the hosted leader. */
41
- const CORS_ALLOW_HEADERS = 'Content-Type, X-Slicc-Raw-Body, X-Session-Id, X-Bridge-Token, Authorization';
90
+ /**
91
+ * Request header carrying the per-process bridge token on cross-origin
92
+ * /api/* calls from a remote allowlisted leader (e.g. sliccy.ai). The
93
+ * webapp's `proxied-fetch.ts` attaches it whenever a local API base
94
+ * origin is set; the thin-bridge CORS middleware validates it. Header
95
+ * is included in `CORS_BASE_ALLOW_HEADERS` so browsers don't strip it
96
+ * on the preflight.
97
+ */
98
+ export const BRIDGE_TOKEN_HEADER = 'X-Bridge-Token';
99
+ /**
100
+ * Headers we allow on cross-origin /api requests from the hosted leader.
101
+ * Includes the `/api/fetch-proxy` transport headers (`X-Target-URL`, the
102
+ * forbidden-header `X-Proxy-*` bridges, `X-Slicc-Raw-Body`) so the
103
+ * webapp's `createProxiedFetch` can route through the local node-server
104
+ * cross-origin in thin-bridge mode. Custom upstream headers (any header
105
+ * the agent's `curl -H …` would pass through) are reflected via
106
+ * `Access-Control-Request-Headers` in `buildCorsHeaders` below.
107
+ */
108
+ const CORS_BASE_ALLOW_HEADERS = [
109
+ 'Content-Type',
110
+ 'X-Slicc-Raw-Body',
111
+ 'X-Session-Id',
112
+ 'X-Bridge-Token',
113
+ 'Authorization',
114
+ 'X-Target-URL',
115
+ 'X-Proxy-Cookie',
116
+ 'X-Proxy-Origin',
117
+ 'X-Proxy-Referer',
118
+ ];
119
+ /**
120
+ * Response headers the browser is allowed to read after a cross-origin
121
+ * /api call — must include the proxy's infrastructure-error marker
122
+ * (`isProxyError` reads `X-Proxy-Error`) and the forbidden-response
123
+ * bridge (`decodeForbiddenResponseHeaders` reads `X-Proxy-Set-Cookie`).
124
+ */
125
+ const CORS_EXPOSE_HEADERS = 'Link, X-Proxy-Error, X-Proxy-Set-Cookie';
42
126
  /** Methods exposed to the hosted leader. */
43
127
  const CORS_ALLOW_METHODS = 'GET, POST, PUT, DELETE, OPTIONS';
44
- /** True iff `origin` is in the bridge allowlist. Case-sensitive (origins are normalized lowercase). */
128
+ /**
129
+ * True iff `origin` is in the bridge allowlist. The frozen prod base
130
+ * (`BRIDGE_ALLOWED_ORIGINS`) is matched case-sensitively against the
131
+ * raw origin; the dev-only env-supplied extras (normalized lowercase
132
+ * + trailing-slash-stripped at load) are matched against a normalized
133
+ * copy of the input, so a leader sending `Origin: HTTP://Localhost:8787`
134
+ * (or with a stray trailing slash) still resolves correctly.
135
+ */
45
136
  export function isAllowedBridgeOrigin(origin) {
46
137
  if (!origin)
47
138
  return false;
48
- return BRIDGE_ALLOWED_ORIGINS.includes(origin);
139
+ if (BRIDGE_ALLOWED_ORIGINS.includes(origin))
140
+ return true;
141
+ if (BRIDGE_DEV_ALLOWED_ORIGINS.size === 0)
142
+ return false;
143
+ const normalized = normalizeDevOrigin(origin);
144
+ if (!normalized)
145
+ return false;
146
+ return BRIDGE_DEV_ALLOWED_ORIGINS.has(normalized);
147
+ }
148
+ /**
149
+ * True iff `origin` is a loopback host (localhost / 127.0.0.1 / ::1).
150
+ * Loopback allowlisted origins (e.g. the locally-served OAuth callback
151
+ * page at `http://localhost:5710/auth/callback` posting to
152
+ * `/api/oauth-result`) are exempt from the bridge-token requirement —
153
+ * the token's threat model is "remote allowlisted origin (sliccy.ai)
154
+ * with a hostile script", not "local server talking to itself".
155
+ */
156
+ export function isLoopbackBridgeOrigin(origin) {
157
+ if (!origin)
158
+ return false;
159
+ try {
160
+ const url = new URL(origin);
161
+ // Node's WHATWG URL parser keeps the brackets on IPv6 hostnames
162
+ // (`http://[::1]:5710` → `[::1]`); accept both bracketed and bare.
163
+ return (url.hostname === 'localhost' ||
164
+ url.hostname === '127.0.0.1' ||
165
+ url.hostname === '::1' ||
166
+ url.hostname === '[::1]');
167
+ }
168
+ catch {
169
+ return false;
170
+ }
171
+ }
172
+ /**
173
+ * Constant-time compare for the bridge token. `presented` may be missing
174
+ * or shaped wrong (Express delivers headers as `string | string[] |
175
+ * undefined`). Returns `false` for any non-string, length mismatch, or
176
+ * empty expected — never throws.
177
+ */
178
+ export function validateBridgeToken(presented, expected) {
179
+ if (!expected)
180
+ return false;
181
+ const value = Array.isArray(presented) ? presented[0] : presented;
182
+ if (typeof value !== 'string' || value.length === 0)
183
+ return false;
184
+ const a = Buffer.from(value);
185
+ const b = Buffer.from(expected);
186
+ if (a.length !== b.length)
187
+ return false;
188
+ return timingSafeEqual(a, b);
49
189
  }
50
190
  /**
51
191
  * Mint a per-process bridge token. Embedded in the leader launch URL and
@@ -102,6 +242,36 @@ export function validateBridgeUpgrade(input) {
102
242
  }
103
243
  return { ok: true, acceptedSubprotocol: accepted };
104
244
  }
245
+ /**
246
+ * Resolve the `Access-Control-Allow-Headers` value for a request. Starts
247
+ * from `CORS_BASE_ALLOW_HEADERS` (the static set covering the documented
248
+ * /api endpoints + the `/api/fetch-proxy` transport headers) and unions
249
+ * in any header names from the request's `Access-Control-Request-Headers`
250
+ * that aren't already listed. This is the reflect-headers pattern: the
251
+ * agent's `bash curl -H X-Custom: …` can route through `/api/fetch-proxy`
252
+ * cross-origin without us having to enumerate every possible upstream
253
+ * header in advance. Comparison is case-insensitive; the static set's
254
+ * canonical casing wins on duplicates.
255
+ */
256
+ export function resolveCorsAllowHeaders(requestHeadersHeader) {
257
+ if (!requestHeadersHeader)
258
+ return CORS_BASE_ALLOW_HEADERS.join(', ');
259
+ const seen = new Set(CORS_BASE_ALLOW_HEADERS.map((h) => h.toLowerCase()));
260
+ const extras = [];
261
+ for (const raw of requestHeadersHeader.split(',')) {
262
+ const name = raw.trim();
263
+ if (!name)
264
+ continue;
265
+ const lower = name.toLowerCase();
266
+ if (seen.has(lower))
267
+ continue;
268
+ seen.add(lower);
269
+ extras.push(name);
270
+ }
271
+ if (extras.length === 0)
272
+ return CORS_BASE_ALLOW_HEADERS.join(', ');
273
+ return [...CORS_BASE_ALLOW_HEADERS, ...extras].join(', ');
274
+ }
105
275
  /**
106
276
  * CORS headers for an allowlisted `Origin`. Returns `null` when the origin
107
277
  * is not in the allowlist (caller should NOT set CORS headers).
@@ -109,17 +279,24 @@ export function validateBridgeUpgrade(input) {
109
279
  * `Access-Control-Allow-Credentials: true` is included so the hosted leader
110
280
  * can carry cookies to /api/* (e.g. auth) when that's added later. Today
111
281
  * the bridge token is the auth factor, not cookies.
282
+ *
283
+ * `requestHeadersHeader` should be the request's `Access-Control-Request-Headers`
284
+ * value (preflight only); on a non-preflight request pass `null` and the
285
+ * caller can omit it.
112
286
  */
113
- export function buildCorsHeaders(origin) {
287
+ export function buildCorsHeaders(origin, requestHeadersHeader) {
114
288
  if (!isAllowedBridgeOrigin(origin))
115
289
  return null;
290
+ const reqHeaders = Array.isArray(requestHeadersHeader)
291
+ ? requestHeadersHeader.join(', ')
292
+ : (requestHeadersHeader ?? null);
116
293
  return {
117
294
  'Access-Control-Allow-Origin': origin,
118
295
  'Access-Control-Allow-Credentials': 'true',
119
296
  'Access-Control-Allow-Methods': CORS_ALLOW_METHODS,
120
- 'Access-Control-Allow-Headers': CORS_ALLOW_HEADERS,
121
- 'Access-Control-Expose-Headers': 'Link',
122
- Vary: 'Origin',
297
+ 'Access-Control-Allow-Headers': resolveCorsAllowHeaders(reqHeaders),
298
+ 'Access-Control-Expose-Headers': CORS_EXPOSE_HEADERS,
299
+ Vary: 'Origin, Access-Control-Request-Headers',
123
300
  };
124
301
  }
125
302
  /**
@@ -3,9 +3,15 @@
3
3
  *
4
4
  * Includes hop-by-hop headers (`host`, `connection`, `transfer-encoding`,
5
5
  * `content-length`), proxy-internal markers (`x-target-url`,
6
- * `x-slicc-raw-body`), and forbidden-header transports the client uses
6
+ * `x-slicc-raw-body`), forbidden-header transports the client uses
7
7
  * to smuggle reserved names through `fetch()` (`x-proxy-cookie`,
8
- * `x-proxy-origin`, `x-proxy-referer`).
8
+ * `x-proxy-origin`, `x-proxy-referer`), and the thin-bridge auth header
9
+ * (`x-bridge-token`) which authenticates the browser->local hop only
10
+ * and must not leak onward to `targetUrl`. The bridge-token middleware
11
+ * (`createThinBridgeCorsMiddleware` in `index.ts`) is mounted ahead of
12
+ * this route and reads `req.headers` directly, so token validation
13
+ * still sees the header - this Set only filters what gets COPIED into
14
+ * the forwarded request.
9
15
  *
10
16
  * Lives in its own module (rather than `index.ts`) so tests can import
11
17
  * it without triggering the server bootstrap that runs at `index.ts`
@@ -3,9 +3,15 @@
3
3
  *
4
4
  * Includes hop-by-hop headers (`host`, `connection`, `transfer-encoding`,
5
5
  * `content-length`), proxy-internal markers (`x-target-url`,
6
- * `x-slicc-raw-body`), and forbidden-header transports the client uses
6
+ * `x-slicc-raw-body`), forbidden-header transports the client uses
7
7
  * to smuggle reserved names through `fetch()` (`x-proxy-cookie`,
8
- * `x-proxy-origin`, `x-proxy-referer`).
8
+ * `x-proxy-origin`, `x-proxy-referer`), and the thin-bridge auth header
9
+ * (`x-bridge-token`) which authenticates the browser->local hop only
10
+ * and must not leak onward to `targetUrl`. The bridge-token middleware
11
+ * (`createThinBridgeCorsMiddleware` in `index.ts`) is mounted ahead of
12
+ * this route and reads `req.headers` directly, so token validation
13
+ * still sees the header - this Set only filters what gets COPIED into
14
+ * the forwarded request.
9
15
  *
10
16
  * Lives in its own module (rather than `index.ts`) so tests can import
11
17
  * it without triggering the server bootstrap that runs at `index.ts`
@@ -21,4 +27,5 @@ export const FETCH_PROXY_SKIP_HEADERS = new Set([
21
27
  'x-proxy-cookie',
22
28
  'x-proxy-origin',
23
29
  'x-proxy-referer',
30
+ 'x-bridge-token',
24
31
  ]);