@floegence/flowersec-core 0.19.0 → 0.19.1

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.
@@ -13,6 +13,13 @@ export class ControlplaneRequestError extends Error {
13
13
  }
14
14
  export const DEFAULT_CONNECT_ARTIFACT_PATH = "/v1/connect/artifact";
15
15
  export const DEFAULT_ENTRY_CONNECT_ARTIFACT_PATH = "/v1/connect/artifact/entry";
16
+ const DEFAULT_MAX_CONTROLPLANE_RESPONSE_BYTES = 1 << 20;
17
+ class ControlplaneResponseTooLargeError extends Error {
18
+ constructor(maxBytes) {
19
+ super(`controlplane response exceeded ${maxBytes} bytes`);
20
+ this.name = "ControlplaneResponseTooLargeError";
21
+ }
22
+ }
16
23
  function resolveFetch(fetchImpl) {
17
24
  if (fetchImpl)
18
25
  return fetchImpl;
@@ -37,6 +44,46 @@ function parseMaybeJSON(bodyText) {
37
44
  return trimmed;
38
45
  }
39
46
  }
47
+ function parseContentLength(headerValue) {
48
+ const raw = String(headerValue ?? "").trim();
49
+ if (raw === "")
50
+ return null;
51
+ if (!/^[0-9]+$/.test(raw))
52
+ return null;
53
+ const parsed = Number(raw);
54
+ if (!Number.isSafeInteger(parsed) || parsed < 0)
55
+ return null;
56
+ return parsed;
57
+ }
58
+ async function readControlplaneText(response, maxBytes) {
59
+ const contentLength = parseContentLength(response.headers.get("Content-Length"));
60
+ if (contentLength !== null && contentLength > maxBytes) {
61
+ throw new ControlplaneResponseTooLargeError(maxBytes);
62
+ }
63
+ if (!response.body) {
64
+ return "";
65
+ }
66
+ const reader = response.body.getReader();
67
+ const decoder = new TextDecoder();
68
+ let totalBytes = 0;
69
+ let text = "";
70
+ while (true) {
71
+ const chunk = await reader.read();
72
+ if (chunk.done)
73
+ break;
74
+ totalBytes += chunk.value.byteLength;
75
+ if (totalBytes > maxBytes) {
76
+ try {
77
+ await reader.cancel();
78
+ }
79
+ catch { }
80
+ throw new ControlplaneResponseTooLargeError(maxBytes);
81
+ }
82
+ text += decoder.decode(chunk.value, { stream: true });
83
+ }
84
+ text += decoder.decode();
85
+ return text;
86
+ }
40
87
  function decodeErrorMessage(status, responseBody) {
41
88
  let message = `controlplane request failed: ${status}`;
42
89
  let code = "";
@@ -61,7 +108,19 @@ export async function requestControlplaneJSON(url, init) {
61
108
  ...init,
62
109
  cache: "no-store",
63
110
  });
64
- const rawBody = await response.text();
111
+ let rawBody = "";
112
+ try {
113
+ rawBody = await readControlplaneText(response, DEFAULT_MAX_CONTROLPLANE_RESPONSE_BYTES);
114
+ }
115
+ catch (err) {
116
+ if (err instanceof ControlplaneResponseTooLargeError && !response.ok) {
117
+ throw new ControlplaneRequestError({
118
+ status: response.status,
119
+ message: err.message,
120
+ });
121
+ }
122
+ throw err;
123
+ }
65
124
  const responseBody = parseMaybeJSON(rawBody);
66
125
  if (!response.ok) {
67
126
  const error = decodeErrorMessage(response.status, responseBody);
@@ -1,6 +1,7 @@
1
1
  export declare class CookieJar {
2
2
  private readonly cookies;
3
- setCookie(setCookieHeader: string): void;
4
- updateFromSetCookieHeaders(headers: readonly string[]): void;
3
+ private nextCreatedAtSeq;
4
+ setCookie(setCookieHeader: string, requestPath?: string): void;
5
+ updateFromSetCookieHeaders(headers: readonly string[], requestPath?: string): void;
5
6
  getCookieHeader(path: string): string;
6
7
  }
@@ -11,9 +11,54 @@ function parseCookieNameValue(s) {
11
11
  return null;
12
12
  return { name, value };
13
13
  }
14
+ function cookieStorageKey(name, path) {
15
+ return `${name}\u0000${path}`;
16
+ }
17
+ function requestPathOnly(path) {
18
+ const raw = path.trim();
19
+ if (!raw.startsWith("/"))
20
+ return "/";
21
+ const q = raw.indexOf("?");
22
+ const out = q >= 0 ? raw.slice(0, q) : raw;
23
+ return out === "" ? "/" : out;
24
+ }
25
+ function defaultCookiePathFromRequestPath(requestPath) {
26
+ const path = requestPathOnly(requestPath);
27
+ if (path === "/")
28
+ return "/";
29
+ const lastSlash = path.lastIndexOf("/");
30
+ if (lastSlash <= 0)
31
+ return "/";
32
+ return path.slice(0, lastSlash);
33
+ }
34
+ function normalizeCookiePath(pathAttr, requestPath) {
35
+ const path = pathAttr?.trim() ?? "";
36
+ if (path === "" || !path.startsWith("/"))
37
+ return defaultCookiePathFromRequestPath(requestPath);
38
+ return path;
39
+ }
40
+ function pathMatchesCookiePath(requestPath, cookiePath) {
41
+ const path = requestPathOnly(requestPath);
42
+ if (cookiePath === "/")
43
+ return true;
44
+ if (path === cookiePath)
45
+ return true;
46
+ if (!path.startsWith(cookiePath))
47
+ return false;
48
+ if (cookiePath.endsWith("/"))
49
+ return true;
50
+ return path.charAt(cookiePath.length) === "/";
51
+ }
52
+ function compareCookiesForHeader(a, b) {
53
+ const pathLenDiff = b.path.length - a.path.length;
54
+ if (pathLenDiff !== 0)
55
+ return pathLenDiff;
56
+ return a.createdAtSeq - b.createdAtSeq;
57
+ }
14
58
  export class CookieJar {
15
59
  cookies = new Map();
16
- setCookie(setCookieHeader) {
60
+ nextCreatedAtSeq = 0;
61
+ setCookie(setCookieHeader, requestPath = "/") {
17
62
  const raw = setCookieHeader.trim();
18
63
  if (raw === "")
19
64
  return;
@@ -23,15 +68,16 @@ export class CookieJar {
23
68
  const nv = parseCookieNameValue(parts[0] ?? "");
24
69
  if (nv == null)
25
70
  return;
26
- let path = "/";
71
+ let pathAttr;
27
72
  let expiresAtMs;
73
+ let maxAgeSeen = false;
28
74
  for (let i = 1; i < parts.length; i++) {
29
75
  const p = parts[i];
30
76
  const lower = p.toLowerCase();
31
77
  if (lower.startsWith("path=")) {
32
78
  const v = p.slice("path=".length).trim();
33
79
  if (v !== "")
34
- path = v;
80
+ pathAttr = v;
35
81
  continue;
36
82
  }
37
83
  if (lower.startsWith("max-age=")) {
@@ -39,14 +85,13 @@ export class CookieJar {
39
85
  const n = Number.parseInt(v, 10);
40
86
  if (!Number.isFinite(n))
41
87
  continue;
42
- if (n <= 0) {
43
- this.cookies.delete(nv.name);
44
- return;
45
- }
46
- expiresAtMs = nowMs() + n * 1000;
88
+ maxAgeSeen = true;
89
+ expiresAtMs = n <= 0 ? 0 : nowMs() + n * 1000;
47
90
  continue;
48
91
  }
49
92
  if (lower.startsWith("expires=")) {
93
+ if (maxAgeSeen)
94
+ continue;
50
95
  const v = p.slice("expires=".length).trim();
51
96
  const t = Date.parse(v);
52
97
  if (!Number.isFinite(t))
@@ -55,34 +100,37 @@ export class CookieJar {
55
100
  continue;
56
101
  }
57
102
  }
58
- // Expired via Expires.
103
+ const path = normalizeCookiePath(pathAttr, requestPath);
104
+ const key = cookieStorageKey(nv.name, path);
59
105
  if (expiresAtMs != null && expiresAtMs <= nowMs()) {
60
- this.cookies.delete(nv.name);
106
+ this.cookies.delete(key);
61
107
  return;
62
108
  }
109
+ const createdAtSeq = this.cookies.get(key)?.createdAtSeq ?? this.nextCreatedAtSeq++;
63
110
  if (expiresAtMs == null) {
64
- this.cookies.set(nv.name, { name: nv.name, value: nv.value, path });
111
+ this.cookies.set(key, { name: nv.name, value: nv.value, path, createdAtSeq });
65
112
  }
66
113
  else {
67
- this.cookies.set(nv.name, { name: nv.name, value: nv.value, path, expiresAtMs });
114
+ this.cookies.set(key, { name: nv.name, value: nv.value, path, expiresAtMs, createdAtSeq });
68
115
  }
69
116
  }
70
- updateFromSetCookieHeaders(headers) {
117
+ updateFromSetCookieHeaders(headers, requestPath = "/") {
71
118
  for (const h of headers)
72
- this.setCookie(h);
119
+ this.setCookie(h, requestPath);
73
120
  }
74
121
  getCookieHeader(path) {
75
122
  const now = nowMs();
76
- const pairs = [];
77
- for (const c of this.cookies.values()) {
123
+ const matched = [];
124
+ for (const [key, c] of this.cookies.entries()) {
78
125
  if (c.expiresAtMs != null && c.expiresAtMs <= now) {
79
- this.cookies.delete(c.name);
126
+ this.cookies.delete(key);
80
127
  continue;
81
128
  }
82
- if (c.path !== "/" && !path.startsWith(c.path))
129
+ if (!pathMatchesCookiePath(path, c.path))
83
130
  continue;
84
- pairs.push(`${c.name}=${c.value}`);
131
+ matched.push(c);
85
132
  }
86
- return pairs.join("; ");
133
+ matched.sort(compareCookiesForHeader);
134
+ return matched.map((c) => `${c.name}=${c.value}`).join("; ");
87
135
  }
88
136
  }
@@ -102,7 +102,7 @@ export function createProxyRuntime(opts) {
102
102
  ctl?.postMessage({ type: "flowersec-proxy:register-runtime" });
103
103
  }
104
104
  catch {
105
- // Best-effort: runtime can still work if SW picks it via matchAll().
105
+ // Best-effort: controllerchange will retry once the active Service Worker is ready.
106
106
  }
107
107
  };
108
108
  const onMessage = (ev) => {
@@ -168,7 +168,7 @@ export function createProxyRuntime(opts) {
168
168
  const status = Math.max(0, Math.floor(respMeta.status ?? 502));
169
169
  const rawHeaders = Array.isArray(respMeta.headers) ? respMeta.headers : [];
170
170
  const { passthrough, setCookie } = filterResponseHeaders(rawHeaders, { extraAllowed: extraResponseHeaders });
171
- cookieJar.updateFromSetCookieHeaders(setCookie);
171
+ cookieJar.updateFromSetCookieHeaders(setCookie, path);
172
172
  port.postMessage({ type: "flowersec-proxy:response_meta", status, headers: passthrough });
173
173
  const chunks = await readChunkFrames(reader, maxChunkBytes, maxBodyBytes);
174
174
  for await (const chunk of chunks) {
@@ -159,6 +159,7 @@ self.addEventListener("install", (event) => {
159
159
  });
160
160
 
161
161
  self.addEventListener("activate", (event) => {
162
+ runtimeClientId = null;
162
163
  event.waitUntil(self.clients.claim());
163
164
  });
164
165
 
@@ -207,16 +208,10 @@ async function getWindowClient(preferredClientId) {
207
208
  return cs.length > 0 ? cs[0] : null;
208
209
  }
209
210
 
210
- if (runtimeClientId) {
211
- const c = await self.clients.get(runtimeClientId);
212
- if (c) return c;
213
- runtimeClientId = null;
214
- }
215
- const cs = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
216
- if (cs.length > 0) {
217
- runtimeClientId = cs[0].id;
218
- return cs[0];
219
- }
211
+ if (!runtimeClientId) return null;
212
+ const c = await self.clients.get(runtimeClientId);
213
+ if (c) return c;
214
+ runtimeClientId = null;
220
215
  return null;
221
216
  }
222
217
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floegence/flowersec-core",
3
- "version": "0.19.0",
3
+ "version": "0.19.1",
4
4
  "description": "Flowersec core TypeScript library (browser-friendly E2EE + multiplexing over WebSocket).",
5
5
  "license": "MIT",
6
6
  "repository": {