@convex-dev/ai-budget 0.0.2-alpha.6 → 0.0.2-alpha.8

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.
@@ -54,8 +54,16 @@ export const DASHBOARD_HTML = String.raw `<!doctype html>
54
54
  </nav>
55
55
  <main id="main"></main>
56
56
  <script>
57
- const API = "__API_BASE__";
58
- const TOKEN = "__TOKEN__";
57
+ // Injected as JSON literals by registerRoutes (no surrounding quotes here).
58
+ const API = __API_BASE__;
59
+ const TOKEN = __TOKEN__;
60
+ // If we were opened with ?token=… (needed for the initial navigation, which
61
+ // can't set headers), drop it from the address bar so it doesn't linger in
62
+ // history; API calls below use the Authorization header instead.
63
+ try {
64
+ const u = new URL(location.href);
65
+ if (u.searchParams.has("token")) { u.searchParams.delete("token"); history.replaceState(null, "", u.toString()); }
66
+ } catch (e) {}
59
67
  const H = TOKEN ? { Authorization: "Bearer " + TOKEN } : {};
60
68
  const NANOS = 1e9;
61
69
  const usd = (n) => n == null ? "—" : "$" + (n / NANOS).toFixed(n && n < NANOS/100 ? 6 : 2);
@@ -96,6 +96,17 @@ function extractText(result) {
96
96
  }
97
97
  return "";
98
98
  }
99
+ // ---------- client ----------
100
+ // Length-independent-branch string compare, so the dashboard token check
101
+ // doesn't leak the token via response timing. (Length itself is not secret.)
102
+ function timingSafeEqual(a, b) {
103
+ if (a.length !== b.length)
104
+ return false;
105
+ let r = 0;
106
+ for (let i = 0; i < a.length; i++)
107
+ r |= a.charCodeAt(i) ^ b.charCodeAt(i);
108
+ return r === 0;
109
+ }
99
110
  export class AIBudget {
100
111
  component;
101
112
  defaultModel;
@@ -485,8 +496,11 @@ export class AIBudget {
485
496
  return { ok: false, token: "" };
486
497
  const url = new URL(request.url);
487
498
  const bearer = (request.headers.get("authorization") ?? "").replace(/^Bearer\s+/i, "");
499
+ // `?token=` is accepted only for the initial page navigation (a browser
500
+ // GET can't set headers); the page strips it from the URL on load and the
501
+ // JSON API is called with the bearer header. Compared in constant time.
488
502
  const provided = bearer || url.searchParams.get("token") || "";
489
- return { ok: provided === token, token };
503
+ return { ok: timingSafeEqual(provided, token), token };
490
504
  };
491
505
  const json = (data, status = 200) => new Response(JSON.stringify(data ?? null), {
492
506
  status,
@@ -548,7 +562,10 @@ export class AIBudget {
548
562
  return json({ error: "not found" }, 404);
549
563
  }
550
564
  }
551
- const html = DASHBOARD_HTML.replace(/__API_BASE__/g, `${prefix}/api`).replace(/__TOKEN__/g, token);
565
+ // Inject as JSON literals (function replacers so `$` in the value isn't
566
+ // treated as a replacement pattern). This keeps a token/prefix containing
567
+ // quotes, backslashes, or `</script>` from breaking out of the JS string.
568
+ const html = DASHBOARD_HTML.replace(/__API_BASE__/g, () => JSON.stringify(`${prefix}/api`)).replace(/__TOKEN__/g, () => JSON.stringify(token));
552
569
  return new Response(html, {
553
570
  headers: { "content-type": "text/html; charset=utf-8" },
554
571
  });
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "email": "support@convex.dev",
8
8
  "url": "https://github.com/get-convex/ai-budget/issues"
9
9
  },
10
- "version": "0.0.2-alpha.6",
10
+ "version": "0.0.2-alpha.8",
11
11
  "license": "Apache-2.0",
12
12
  "type": "module",
13
13
  "keywords": [
@@ -54,8 +54,16 @@ export const DASHBOARD_HTML = String.raw`<!doctype html>
54
54
  </nav>
55
55
  <main id="main"></main>
56
56
  <script>
57
- const API = "__API_BASE__";
58
- const TOKEN = "__TOKEN__";
57
+ // Injected as JSON literals by registerRoutes (no surrounding quotes here).
58
+ const API = __API_BASE__;
59
+ const TOKEN = __TOKEN__;
60
+ // If we were opened with ?token=… (needed for the initial navigation, which
61
+ // can't set headers), drop it from the address bar so it doesn't linger in
62
+ // history; API calls below use the Authorization header instead.
63
+ try {
64
+ const u = new URL(location.href);
65
+ if (u.searchParams.has("token")) { u.searchParams.delete("token"); history.replaceState(null, "", u.toString()); }
66
+ } catch (e) {}
59
67
  const H = TOKEN ? { Authorization: "Bearer " + TOKEN } : {};
60
68
  const NANOS = 1e9;
61
69
  const usd = (n) => n == null ? "—" : "$" + (n / NANOS).toFixed(n && n < NANOS/100 ? 6 : 2);
@@ -221,6 +221,15 @@ function extractText(result: any): string {
221
221
 
222
222
  // ---------- client ----------
223
223
 
224
+ // Length-independent-branch string compare, so the dashboard token check
225
+ // doesn't leak the token via response timing. (Length itself is not secret.)
226
+ function timingSafeEqual(a: string, b: string): boolean {
227
+ if (a.length !== b.length) return false;
228
+ let r = 0;
229
+ for (let i = 0; i < a.length; i++) r |= a.charCodeAt(i) ^ b.charCodeAt(i);
230
+ return r === 0;
231
+ }
232
+
224
233
  /** Limits/controls settable on any budget bucket (user, action, or tag). */
225
234
  export type BucketLimits = {
226
235
  requestsPerMinute?: number;
@@ -756,8 +765,11 @@ export class AIBudget {
756
765
  if (!token) return { ok: false, token: "" };
757
766
  const url = new URL(request.url);
758
767
  const bearer = (request.headers.get("authorization") ?? "").replace(/^Bearer\s+/i, "");
768
+ // `?token=` is accepted only for the initial page navigation (a browser
769
+ // GET can't set headers); the page strips it from the URL on load and the
770
+ // JSON API is called with the bearer header. Compared in constant time.
759
771
  const provided = bearer || url.searchParams.get("token") || "";
760
- return { ok: provided === token, token };
772
+ return { ok: timingSafeEqual(provided, token), token };
761
773
  };
762
774
  const json = (data: unknown, status = 200) =>
763
775
  new Response(JSON.stringify(data ?? null), {
@@ -827,10 +839,13 @@ export class AIBudget {
827
839
  }
828
840
  }
829
841
 
830
- const html = DASHBOARD_HTML.replace(/__API_BASE__/g, `${prefix}/api`).replace(
831
- /__TOKEN__/g,
832
- token
833
- );
842
+ // Inject as JSON literals (function replacers so `$` in the value isn't
843
+ // treated as a replacement pattern). This keeps a token/prefix containing
844
+ // quotes, backslashes, or `</script>` from breaking out of the JS string.
845
+ const html = DASHBOARD_HTML.replace(
846
+ /__API_BASE__/g,
847
+ () => JSON.stringify(`${prefix}/api`)
848
+ ).replace(/__TOKEN__/g, () => JSON.stringify(token));
834
849
  return new Response(html, {
835
850
  headers: { "content-type": "text/html; charset=utf-8" },
836
851
  });