@convex-dev/ai-budget 0.0.2-alpha.7 → 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.
- package/dist/client/dashboard.js +10 -2
- package/dist/client/index.js +19 -2
- package/package.json +1 -1
- package/src/client/dashboard.ts +10 -2
- package/src/client/index.ts +20 -5
package/dist/client/dashboard.js
CHANGED
|
@@ -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
|
-
|
|
58
|
-
const
|
|
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);
|
package/dist/client/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
package/src/client/dashboard.ts
CHANGED
|
@@ -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
|
-
|
|
58
|
-
const
|
|
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);
|
package/src/client/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
831
|
-
|
|
832
|
-
|
|
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
|
});
|