sliccy 4.22.1 → 4.24.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 (80) hide show
  1. package/dist/node-server/bridge-security.d.ts +135 -0
  2. package/dist/node-server/bridge-security.js +245 -0
  3. package/dist/node-server/index.js +290 -48
  4. package/dist/node-server/launch-url.d.ts +7 -0
  5. package/dist/node-server/launch-url.js +12 -3
  6. package/dist/ui/assets/{account-store-ibgiPI6V.js → account-store-BXT0RMpF.js} +2 -2
  7. package/dist/ui/assets/{account-store-4ECV8DGT.js → account-store-CcqPXFk-.js} +2 -2
  8. package/dist/ui/assets/{adobe--TTRq2If.js → adobe-Dvkc5_oM.js} +1 -1
  9. package/dist/ui/assets/adobe-Dyvxqy9s.js +1 -0
  10. package/dist/ui/assets/{agent-message-to-chat-biPexDDJ.js → agent-message-to-chat-Cihra740.js} +1 -1
  11. package/dist/ui/assets/{apps-BOvmrPiX.js → apps-CPyXd9Mk.js} +1 -1
  12. package/dist/ui/assets/{azure-openai-Bf3TmJ0y.js → azure-openai-BEec8FHP.js} +1 -1
  13. package/dist/ui/assets/{azure-openai-DjY0l719.js → azure-openai-lMn0o-9C.js} +1 -1
  14. package/dist/ui/assets/{bsh-watchdog-BVbry18Y.js → bsh-watchdog-Dgr_OA1N.js} +1 -1
  15. package/dist/ui/assets/{cdp-Cbi5Katl.js → cdp-D4JkCTey.js} +3 -3
  16. package/dist/ui/assets/{connect-surface-DSA8Cz3z.js → connect-surface-BzhaxP0L.js} +1 -1
  17. package/dist/ui/assets/dip-CIhLisNq.js +1 -0
  18. package/dist/ui/assets/{dist-BElcUj_V.js → dist-ChTQy8at.js} +1 -1
  19. package/dist/ui/assets/{dist-DP3_V4hR.js → dist-DIjck1q7.js} +1 -1
  20. package/dist/ui/assets/{es-DcI8bAtA.js → es-B8GxLLfB.js} +1 -1
  21. package/dist/ui/assets/{fs-DKIJdRIl.js → fs-ATai1P5Q.js} +2 -2
  22. package/dist/ui/assets/{fs-BQEkHWJV.js → fs-xQUivn-8.js} +1 -1
  23. package/dist/ui/assets/{github-BQBIaABC.js → github-C6vLSHER.js} +2 -2
  24. package/dist/ui/assets/{github-0YLv6nLk.js → github-CUVBUETF.js} +1 -1
  25. package/dist/ui/assets/{github-copilot-DwyTNkwX.js → github-copilot-BqMS3_4S.js} +1 -1
  26. package/dist/ui/assets/{github-copilot-Co_dxCXe.js → github-copilot-ggchxbZd.js} +1 -1
  27. package/dist/ui/assets/{hear-DyUkFgOW.js → hear-Bm8tL0z2.js} +1 -1
  28. package/dist/ui/assets/{kernel-worker-DvmZjl24.js → kernel-worker-CqATTm08.js} +1026 -1026
  29. package/dist/ui/assets/{kokoro-engine-BnI4fL_p.js → kokoro-engine-BzgKX1A1.js} +1 -1
  30. package/dist/ui/assets/{lick-ws-bridge-B0_yyU80.js → lick-ws-bridge-DFQu2sM8.js} +1 -1
  31. package/dist/ui/assets/{local-llm-DnPuhfxf.js → local-llm-D09l_nRy.js} +1 -1
  32. package/dist/ui/assets/{main-cherry-Bap_NTtV.js → main-cherry-B1mI8ArD.js} +1 -1
  33. package/dist/ui/assets/{main-DRGnmneg.js → main-sWMjBmPA.js} +3 -3
  34. package/dist/ui/assets/{mount-ByUTNmzv.js → mount-D7RXkT90.js} +1 -1
  35. package/dist/ui/assets/{mount-CguScujU.js → mount-sEjqfa02.js} +2 -2
  36. package/dist/ui/assets/{new-session-CwnHjnd0.js → new-session-B0xwp5hO.js} +2 -2
  37. package/dist/ui/assets/{oauth-bootstrap-Py4nRtkM.js → oauth-bootstrap-Uz3oksBR.js} +2 -2
  38. package/dist/ui/assets/{openai-codex-B2qaIik0.js → openai-codex-BMr_sYj2.js} +1 -1
  39. package/dist/ui/assets/{openai-codex-0L44_lSb.js → openai-codex-d2uBt8CN.js} +1 -1
  40. package/dist/ui/assets/{panel-rpc-handlers-D7cbHQYp.js → panel-rpc-handlers-1U8M-tQU.js} +1 -1
  41. package/dist/ui/assets/{provider-BI7Y3RBy.js → provider-B_3MhsjL.js} +2 -2
  42. package/dist/ui/assets/{provider-riIj2TBx.js → provider-BbwD43pe.js} +1 -1
  43. package/dist/ui/assets/provider-store-access-Cop2VuvO.js +1 -0
  44. package/dist/ui/assets/provider-store-access-_8NADMDP.js +1 -0
  45. package/dist/ui/assets/{providers-fz0Sm6ju.js → providers-DRQ-n4v0.js} +1 -1
  46. package/dist/ui/assets/proxied-fetch-3ooUUAsF.js +1 -0
  47. package/dist/ui/assets/{quick-llm-B9FBP8Bs.js → quick-llm-CHWi-jSD.js} +1 -1
  48. package/dist/ui/assets/session-freezer-mzV5cASX.js +1 -0
  49. package/dist/ui/assets/setup-sudo-DSt9eI8G.js +1 -0
  50. package/dist/ui/assets/{speak-DVyJlZaU.js → speak-Cr05k3rj.js} +1 -1
  51. package/dist/ui/assets/{sprinkle-manager-O6TFBdwJ.js → sprinkle-manager-B7xN0WFf.js} +1 -1
  52. package/dist/ui/assets/{store-BsGfQ4o5.js → store-CfuBHukr.js} +1 -1
  53. package/dist/ui/assets/{sudo-C-n2ZT92.js → sudo-CnG9mh3w.js} +1 -1
  54. package/dist/ui/assets/{transformers-env-Dpnex-yX.js → transformers-env-BEM0HkoC.js} +1 -1
  55. package/dist/ui/assets/{tray-leave-runtime-MYPnSSR_.js → tray-leave-runtime-CD-xjbXc.js} +1 -1
  56. package/dist/ui/assets/{upgrade-detection-uSF5N6Ew.js → upgrade-detection-CwNs_M6E.js} +1 -1
  57. package/dist/ui/assets/{wc-attach--tUlEIYg.js → wc-attach-D7JTvNE3.js} +2 -2
  58. package/dist/ui/assets/{wc-detached-D6RhlJwc.js → wc-detached-DfgIMcuk.js} +1 -1
  59. package/dist/ui/assets/{wc-extension-DeqnwDO0.js → wc-extension-BNpR2nAA.js} +2 -2
  60. package/dist/ui/assets/{wc-live-DIowT4eF.js → wc-live-cbtCpYYl.js} +12 -12
  61. package/dist/ui/assets/{wc-nav-DI67gu77.js → wc-nav-BVFjm1zp.js} +2 -2
  62. package/dist/ui/assets/{wc-onboarding-BIG01YkV.js → wc-onboarding-C-b3gfT3.js} +2 -2
  63. package/dist/ui/assets/{wc-placeholder-Nry9IKS2.js → wc-placeholder-CLNwZTGs.js} +3 -3
  64. package/dist/ui/assets/{wc-settings-C8nodqL5.js → wc-settings-BRCPA5ys.js} +2 -2
  65. package/dist/ui/assets/{wc-shell-Ng-YAcjd.js → wc-shell-CeWID7O0.js} +3 -3
  66. package/dist/ui/assets/{wc-sprinkles-BWzmaQEm.js → wc-sprinkles-DYTck3a-.js} +2 -2
  67. package/dist/ui/assets/{wc-tray-CbkvHS3n.js → wc-tray-ByE1fpIQ.js} +3 -3
  68. package/dist/ui/assets/{xai-grok-uc4Un-5T.js → xai-grok-BcQs9t1L.js} +1 -1
  69. package/dist/ui/assets/{xai-grok-CZfi469b.js → xai-grok-Bx-LJDfy.js} +1 -1
  70. package/dist/ui/index.html +2 -2
  71. package/dist/ui/packages/webapp/index.html +2 -2
  72. package/package.json +1 -1
  73. package/dist/ui/assets/adobe-CQr1UAcb.js +0 -1
  74. package/dist/ui/assets/dip-6KkNyCHS.js +0 -1
  75. package/dist/ui/assets/provider-store-access-BHYTqSuW.js +0 -1
  76. package/dist/ui/assets/provider-store-access-BP2Nfrp_.js +0 -1
  77. package/dist/ui/assets/proxied-fetch-Dg114MVx.js +0 -1
  78. package/dist/ui/assets/proxy-error-9UvXMpzG.js +0 -1
  79. package/dist/ui/assets/session-freezer-BAK6UVLW.js +0 -1
  80. package/dist/ui/assets/setup-sudo-BMcUrWtK.js +0 -1
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Bridge security primitives for the standalone thin /cdp bridge.
3
+ *
4
+ * The standalone CLI runs a thin Express server that proxies CDP from a
5
+ * sliccy.ai-hosted leader tab to the local Chrome over `/cdp`. Full CDP
6
+ * pass-through = full control of the user's Chrome, so the WebSocket
7
+ * upgrade is gated by two factors plus PNA:
8
+ * 1. Origin allowlist (`isAllowedBridgeOrigin`).
9
+ * 2. Per-process subprotocol token in `Sec-WebSocket-Protocol`
10
+ * (`SUBPROTOCOL_PREFIX` + token). Never appears in a query string,
11
+ * so it does not leak into Referer / logs.
12
+ * 3. PNA preflight (`buildPnaPreflightHeaders`) — Chrome blocks
13
+ * public→private WS upgrades without `Access-Control-Allow-Private-Network`.
14
+ *
15
+ * Cross-origin /api calls from the hosted leader also need CORS; see
16
+ * `buildCorsHeaders` for the per-request response set.
17
+ *
18
+ * Pure module (no node-only imports) so the WS gate is trivially unit
19
+ * testable from `tests/bridge-security.test.ts`.
20
+ */
21
+ /**
22
+ * Origin allowlist. Production + staging worker plus the dev-mode loopback
23
+ * origins (parallel to chrome-extension's `BRIDGE_DEV_ORIGINS`). Add a new
24
+ * origin here and in the extension allowlist together — they MUST stay in
25
+ * sync, otherwise extension and standalone disagree on what's a leader.
26
+ */
27
+ export declare const BRIDGE_ALLOWED_ORIGINS: readonly string[];
28
+ /** Subprotocol prefix advertised by the leader; the per-process token is appended. */
29
+ export declare const BRIDGE_SUBPROTOCOL_PREFIX = "slicc.bridge.v1.";
30
+ /** Query-param name the launch URL uses to forward the subprotocol token to the leader. */
31
+ export declare const BRIDGE_TOKEN_QUERY_PARAM = "bridgeToken";
32
+ /** Query-param name the launch URL uses to forward the local /cdp WebSocket URL. */
33
+ export declare const BRIDGE_WS_QUERY_PARAM = "bridge";
34
+ /**
35
+ * Request header carrying the per-process bridge token on cross-origin
36
+ * /api/* calls from a remote allowlisted leader (e.g. sliccy.ai). The
37
+ * webapp's `proxied-fetch.ts` attaches it whenever a local API base
38
+ * origin is set; the thin-bridge CORS middleware validates it. Header
39
+ * is included in `CORS_BASE_ALLOW_HEADERS` so browsers don't strip it
40
+ * on the preflight.
41
+ */
42
+ export declare const BRIDGE_TOKEN_HEADER = "X-Bridge-Token";
43
+ /** True iff `origin` is in the bridge allowlist. Case-sensitive (origins are normalized lowercase). */
44
+ export declare function isAllowedBridgeOrigin(origin: string | undefined | null): boolean;
45
+ /**
46
+ * True iff `origin` is a loopback host (localhost / 127.0.0.1 / ::1).
47
+ * Loopback allowlisted origins (e.g. the locally-served OAuth callback
48
+ * page at `http://localhost:5710/auth/callback` posting to
49
+ * `/api/oauth-result`) are exempt from the bridge-token requirement —
50
+ * the token's threat model is "remote allowlisted origin (sliccy.ai)
51
+ * with a hostile script", not "local server talking to itself".
52
+ */
53
+ export declare function isLoopbackBridgeOrigin(origin: string | undefined | null): boolean;
54
+ /**
55
+ * Constant-time compare for the bridge token. `presented` may be missing
56
+ * or shaped wrong (Express delivers headers as `string | string[] |
57
+ * undefined`). Returns `false` for any non-string, length mismatch, or
58
+ * empty expected — never throws.
59
+ */
60
+ export declare function validateBridgeToken(presented: string | string[] | undefined, expected: string | null): boolean;
61
+ /**
62
+ * Mint a per-process bridge token. Embedded in the leader launch URL and
63
+ * required as the WebSocket subprotocol on /cdp. Use `crypto.randomUUID()`
64
+ * — 122 bits of entropy is plenty for a session-scoped capability.
65
+ */
66
+ export declare function mintBridgeToken(): string;
67
+ /**
68
+ * Parse the `Sec-WebSocket-Protocol` request header into a trimmed list. The
69
+ * header is a comma-separated list per RFC 6455; `ws` exposes it raw.
70
+ */
71
+ export declare function parseSubprotocolHeader(header: string | string[] | undefined): string[];
72
+ /**
73
+ * Pick the bridge subprotocol matching `expectedToken`, or null if absent.
74
+ * The matching protocol is what we MUST echo back in the upgrade response
75
+ * (RFC 6455 §1.9) — otherwise the browser closes the socket.
76
+ */
77
+ export declare function selectBridgeSubprotocol(protocols: readonly string[], expectedToken: string): string | null;
78
+ export interface BridgeUpgradeGateResult {
79
+ ok: boolean;
80
+ /**
81
+ * The subprotocol to echo back in the 101 response when `ok === true`.
82
+ * Always null when `ok === false`.
83
+ */
84
+ acceptedSubprotocol: string | null;
85
+ /**
86
+ * Reason exposed in logs for rejection. Intentionally coarse — does not
87
+ * tell the caller WHICH check failed, mirroring `validateBridgePin` in
88
+ * the chrome-extension bridge SW.
89
+ */
90
+ reason?: 'origin-not-allowed' | 'subprotocol-missing-or-mismatched';
91
+ }
92
+ /**
93
+ * Combined origin + subprotocol gate for a `/cdp` upgrade request.
94
+ *
95
+ * Returns `{ ok: true, acceptedSubprotocol }` only when BOTH the origin is
96
+ * in the allowlist AND a matching `slicc.bridge.v1.<expectedToken>`
97
+ * subprotocol was offered. Closes-the-socket-before-emit semantics live at
98
+ * the call site in `index.ts`.
99
+ */
100
+ export declare function validateBridgeUpgrade(input: {
101
+ origin: string | undefined | null;
102
+ subprotocolHeader: string | string[] | undefined;
103
+ expectedToken: string;
104
+ }): BridgeUpgradeGateResult;
105
+ /**
106
+ * Resolve the `Access-Control-Allow-Headers` value for a request. Starts
107
+ * from `CORS_BASE_ALLOW_HEADERS` (the static set covering the documented
108
+ * /api endpoints + the `/api/fetch-proxy` transport headers) and unions
109
+ * in any header names from the request's `Access-Control-Request-Headers`
110
+ * that aren't already listed. This is the reflect-headers pattern: the
111
+ * agent's `bash curl -H X-Custom: …` can route through `/api/fetch-proxy`
112
+ * cross-origin without us having to enumerate every possible upstream
113
+ * header in advance. Comparison is case-insensitive; the static set's
114
+ * canonical casing wins on duplicates.
115
+ */
116
+ export declare function resolveCorsAllowHeaders(requestHeadersHeader: string | undefined | null): string;
117
+ /**
118
+ * CORS headers for an allowlisted `Origin`. Returns `null` when the origin
119
+ * is not in the allowlist (caller should NOT set CORS headers).
120
+ *
121
+ * `Access-Control-Allow-Credentials: true` is included so the hosted leader
122
+ * can carry cookies to /api/* (e.g. auth) when that's added later. Today
123
+ * the bridge token is the auth factor, not cookies.
124
+ *
125
+ * `requestHeadersHeader` should be the request's `Access-Control-Request-Headers`
126
+ * value (preflight only); on a non-preflight request pass `null` and the
127
+ * caller can omit it.
128
+ */
129
+ export declare function buildCorsHeaders(origin: string | undefined | null, requestHeadersHeader?: string | string[] | null): Record<string, string> | null;
130
+ /**
131
+ * PNA preflight extras. Added on OPTIONS responses for allowlisted origins
132
+ * when the request carries `Access-Control-Request-Private-Network: true`
133
+ * — Chrome blocks public→private fetch / WS otherwise.
134
+ */
135
+ export declare function buildPnaPreflightHeaders(): Record<string, string>;
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Bridge security primitives for the standalone thin /cdp bridge.
3
+ *
4
+ * The standalone CLI runs a thin Express server that proxies CDP from a
5
+ * sliccy.ai-hosted leader tab to the local Chrome over `/cdp`. Full CDP
6
+ * pass-through = full control of the user's Chrome, so the WebSocket
7
+ * upgrade is gated by two factors plus PNA:
8
+ * 1. Origin allowlist (`isAllowedBridgeOrigin`).
9
+ * 2. Per-process subprotocol token in `Sec-WebSocket-Protocol`
10
+ * (`SUBPROTOCOL_PREFIX` + token). Never appears in a query string,
11
+ * so it does not leak into Referer / logs.
12
+ * 3. PNA preflight (`buildPnaPreflightHeaders`) — Chrome blocks
13
+ * public→private WS upgrades without `Access-Control-Allow-Private-Network`.
14
+ *
15
+ * Cross-origin /api calls from the hosted leader also need CORS; see
16
+ * `buildCorsHeaders` for the per-request response set.
17
+ *
18
+ * Pure module (no node-only imports) so the WS gate is trivially unit
19
+ * testable from `tests/bridge-security.test.ts`.
20
+ */
21
+ import { randomUUID, timingSafeEqual } from 'node:crypto';
22
+ /**
23
+ * Origin allowlist. Production + staging worker plus the dev-mode loopback
24
+ * origins (parallel to chrome-extension's `BRIDGE_DEV_ORIGINS`). Add a new
25
+ * origin here and in the extension allowlist together — they MUST stay in
26
+ * sync, otherwise extension and standalone disagree on what's a leader.
27
+ */
28
+ export const BRIDGE_ALLOWED_ORIGINS = Object.freeze([
29
+ 'https://www.sliccy.ai',
30
+ 'https://slicc-tray-hub-staging.minivelos.workers.dev',
31
+ 'http://localhost:5710',
32
+ 'http://127.0.0.1:5710',
33
+ ]);
34
+ /** Subprotocol prefix advertised by the leader; the per-process token is appended. */
35
+ export const BRIDGE_SUBPROTOCOL_PREFIX = 'slicc.bridge.v1.';
36
+ /** Query-param name the launch URL uses to forward the subprotocol token to the leader. */
37
+ export const BRIDGE_TOKEN_QUERY_PARAM = 'bridgeToken';
38
+ /** Query-param name the launch URL uses to forward the local /cdp WebSocket URL. */
39
+ export const BRIDGE_WS_QUERY_PARAM = 'bridge';
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 const BRIDGE_TOKEN_HEADER = 'X-Bridge-Token';
49
+ /**
50
+ * Headers we allow on cross-origin /api requests from the hosted leader.
51
+ * Includes the `/api/fetch-proxy` transport headers (`X-Target-URL`, the
52
+ * forbidden-header `X-Proxy-*` bridges, `X-Slicc-Raw-Body`) so the
53
+ * webapp's `createProxiedFetch` can route through the local node-server
54
+ * cross-origin in thin-bridge mode. Custom upstream headers (any header
55
+ * the agent's `curl -H …` would pass through) are reflected via
56
+ * `Access-Control-Request-Headers` in `buildCorsHeaders` below.
57
+ */
58
+ const CORS_BASE_ALLOW_HEADERS = [
59
+ 'Content-Type',
60
+ 'X-Slicc-Raw-Body',
61
+ 'X-Session-Id',
62
+ 'X-Bridge-Token',
63
+ 'Authorization',
64
+ 'X-Target-URL',
65
+ 'X-Proxy-Cookie',
66
+ 'X-Proxy-Origin',
67
+ 'X-Proxy-Referer',
68
+ ];
69
+ /**
70
+ * Response headers the browser is allowed to read after a cross-origin
71
+ * /api call — must include the proxy's infrastructure-error marker
72
+ * (`isProxyError` reads `X-Proxy-Error`) and the forbidden-response
73
+ * bridge (`decodeForbiddenResponseHeaders` reads `X-Proxy-Set-Cookie`).
74
+ */
75
+ const CORS_EXPOSE_HEADERS = 'Link, X-Proxy-Error, X-Proxy-Set-Cookie';
76
+ /** Methods exposed to the hosted leader. */
77
+ const CORS_ALLOW_METHODS = 'GET, POST, PUT, DELETE, OPTIONS';
78
+ /** True iff `origin` is in the bridge allowlist. Case-sensitive (origins are normalized lowercase). */
79
+ export function isAllowedBridgeOrigin(origin) {
80
+ if (!origin)
81
+ return false;
82
+ return BRIDGE_ALLOWED_ORIGINS.includes(origin);
83
+ }
84
+ /**
85
+ * True iff `origin` is a loopback host (localhost / 127.0.0.1 / ::1).
86
+ * Loopback allowlisted origins (e.g. the locally-served OAuth callback
87
+ * page at `http://localhost:5710/auth/callback` posting to
88
+ * `/api/oauth-result`) are exempt from the bridge-token requirement —
89
+ * the token's threat model is "remote allowlisted origin (sliccy.ai)
90
+ * with a hostile script", not "local server talking to itself".
91
+ */
92
+ export function isLoopbackBridgeOrigin(origin) {
93
+ if (!origin)
94
+ return false;
95
+ try {
96
+ const url = new URL(origin);
97
+ // Node's WHATWG URL parser keeps the brackets on IPv6 hostnames
98
+ // (`http://[::1]:5710` → `[::1]`); accept both bracketed and bare.
99
+ return (url.hostname === 'localhost' ||
100
+ url.hostname === '127.0.0.1' ||
101
+ url.hostname === '::1' ||
102
+ url.hostname === '[::1]');
103
+ }
104
+ catch {
105
+ return false;
106
+ }
107
+ }
108
+ /**
109
+ * Constant-time compare for the bridge token. `presented` may be missing
110
+ * or shaped wrong (Express delivers headers as `string | string[] |
111
+ * undefined`). Returns `false` for any non-string, length mismatch, or
112
+ * empty expected — never throws.
113
+ */
114
+ export function validateBridgeToken(presented, expected) {
115
+ if (!expected)
116
+ return false;
117
+ const value = Array.isArray(presented) ? presented[0] : presented;
118
+ if (typeof value !== 'string' || value.length === 0)
119
+ return false;
120
+ const a = Buffer.from(value);
121
+ const b = Buffer.from(expected);
122
+ if (a.length !== b.length)
123
+ return false;
124
+ return timingSafeEqual(a, b);
125
+ }
126
+ /**
127
+ * Mint a per-process bridge token. Embedded in the leader launch URL and
128
+ * required as the WebSocket subprotocol on /cdp. Use `crypto.randomUUID()`
129
+ * — 122 bits of entropy is plenty for a session-scoped capability.
130
+ */
131
+ export function mintBridgeToken() {
132
+ return randomUUID();
133
+ }
134
+ /**
135
+ * Parse the `Sec-WebSocket-Protocol` request header into a trimmed list. The
136
+ * header is a comma-separated list per RFC 6455; `ws` exposes it raw.
137
+ */
138
+ export function parseSubprotocolHeader(header) {
139
+ if (!header)
140
+ return [];
141
+ const flat = Array.isArray(header) ? header.join(',') : header;
142
+ return flat
143
+ .split(',')
144
+ .map((s) => s.trim())
145
+ .filter(Boolean);
146
+ }
147
+ /**
148
+ * Pick the bridge subprotocol matching `expectedToken`, or null if absent.
149
+ * The matching protocol is what we MUST echo back in the upgrade response
150
+ * (RFC 6455 §1.9) — otherwise the browser closes the socket.
151
+ */
152
+ export function selectBridgeSubprotocol(protocols, expectedToken) {
153
+ if (!expectedToken)
154
+ return null;
155
+ const expected = `${BRIDGE_SUBPROTOCOL_PREFIX}${expectedToken}`;
156
+ return protocols.includes(expected) ? expected : null;
157
+ }
158
+ /**
159
+ * Combined origin + subprotocol gate for a `/cdp` upgrade request.
160
+ *
161
+ * Returns `{ ok: true, acceptedSubprotocol }` only when BOTH the origin is
162
+ * in the allowlist AND a matching `slicc.bridge.v1.<expectedToken>`
163
+ * subprotocol was offered. Closes-the-socket-before-emit semantics live at
164
+ * the call site in `index.ts`.
165
+ */
166
+ export function validateBridgeUpgrade(input) {
167
+ if (!isAllowedBridgeOrigin(input.origin)) {
168
+ return { ok: false, acceptedSubprotocol: null, reason: 'origin-not-allowed' };
169
+ }
170
+ const protocols = parseSubprotocolHeader(input.subprotocolHeader);
171
+ const accepted = selectBridgeSubprotocol(protocols, input.expectedToken);
172
+ if (!accepted) {
173
+ return {
174
+ ok: false,
175
+ acceptedSubprotocol: null,
176
+ reason: 'subprotocol-missing-or-mismatched',
177
+ };
178
+ }
179
+ return { ok: true, acceptedSubprotocol: accepted };
180
+ }
181
+ /**
182
+ * Resolve the `Access-Control-Allow-Headers` value for a request. Starts
183
+ * from `CORS_BASE_ALLOW_HEADERS` (the static set covering the documented
184
+ * /api endpoints + the `/api/fetch-proxy` transport headers) and unions
185
+ * in any header names from the request's `Access-Control-Request-Headers`
186
+ * that aren't already listed. This is the reflect-headers pattern: the
187
+ * agent's `bash curl -H X-Custom: …` can route through `/api/fetch-proxy`
188
+ * cross-origin without us having to enumerate every possible upstream
189
+ * header in advance. Comparison is case-insensitive; the static set's
190
+ * canonical casing wins on duplicates.
191
+ */
192
+ export function resolveCorsAllowHeaders(requestHeadersHeader) {
193
+ if (!requestHeadersHeader)
194
+ return CORS_BASE_ALLOW_HEADERS.join(', ');
195
+ const seen = new Set(CORS_BASE_ALLOW_HEADERS.map((h) => h.toLowerCase()));
196
+ const extras = [];
197
+ for (const raw of requestHeadersHeader.split(',')) {
198
+ const name = raw.trim();
199
+ if (!name)
200
+ continue;
201
+ const lower = name.toLowerCase();
202
+ if (seen.has(lower))
203
+ continue;
204
+ seen.add(lower);
205
+ extras.push(name);
206
+ }
207
+ if (extras.length === 0)
208
+ return CORS_BASE_ALLOW_HEADERS.join(', ');
209
+ return [...CORS_BASE_ALLOW_HEADERS, ...extras].join(', ');
210
+ }
211
+ /**
212
+ * CORS headers for an allowlisted `Origin`. Returns `null` when the origin
213
+ * is not in the allowlist (caller should NOT set CORS headers).
214
+ *
215
+ * `Access-Control-Allow-Credentials: true` is included so the hosted leader
216
+ * can carry cookies to /api/* (e.g. auth) when that's added later. Today
217
+ * the bridge token is the auth factor, not cookies.
218
+ *
219
+ * `requestHeadersHeader` should be the request's `Access-Control-Request-Headers`
220
+ * value (preflight only); on a non-preflight request pass `null` and the
221
+ * caller can omit it.
222
+ */
223
+ export function buildCorsHeaders(origin, requestHeadersHeader) {
224
+ if (!isAllowedBridgeOrigin(origin))
225
+ return null;
226
+ const reqHeaders = Array.isArray(requestHeadersHeader)
227
+ ? requestHeadersHeader.join(', ')
228
+ : (requestHeadersHeader ?? null);
229
+ return {
230
+ 'Access-Control-Allow-Origin': origin,
231
+ 'Access-Control-Allow-Credentials': 'true',
232
+ 'Access-Control-Allow-Methods': CORS_ALLOW_METHODS,
233
+ 'Access-Control-Allow-Headers': resolveCorsAllowHeaders(reqHeaders),
234
+ 'Access-Control-Expose-Headers': CORS_EXPOSE_HEADERS,
235
+ Vary: 'Origin, Access-Control-Request-Headers',
236
+ };
237
+ }
238
+ /**
239
+ * PNA preflight extras. Added on OPTIONS responses for allowlisted origins
240
+ * when the request carries `Access-Control-Request-Private-Network: true`
241
+ * — Chrome blocks public→private fetch / WS otherwise.
242
+ */
243
+ export function buildPnaPreflightHeaders() {
244
+ return { 'Access-Control-Allow-Private-Network': 'true' };
245
+ }