Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,10 @@ Measured, dated, and recorded in `docs/verification.md`. Nothing below is estima
### 5. Hosted version (optional)

- If a hosted copy is linked at submission, it runs in replay mode: no account and no keys needed.
- The website's "Ask by voice" is the only part that calls a paid model. It answers only the site's own pages, is
rate-limited per visitor by a Vercel firewall rule (`voice-transcribe`, via `@vercel/firewall`), has a daily cap
(`VOICE_DAILY_CAP`, default 300), and can be switched off at once with `VOICE_DEMO=off`. The preset questions always
work.
- Everything also runs locally from the steps above.

### 6. Environment variables
Expand Down
1 change: 1 addition & 0 deletions THIRD_PARTY.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ Direct dependencies only. Licences below are as published by each project.
| What | Used for | Licence |
|---|---|---|
| Next.js | Web framework | MIT |
| `@vercel/firewall` 1.2.6 | The voice demo route asks Vercel's firewall rule `voice-transcribe` whether a visitor is over the limit | Apache-2.0 (npm `license` field, checked 2026-11-01). Used as an npm dependency. |
| React | UI | MIT |
| three.js (`three` 0.186.1) | Point-cloud rendering in the 3D viewer | MIT |
| react-three-fiber (`@react-three/fiber` 9.8.2) | React renderer for three.js (`apps/web/components/viewer/`) | MIT |
Expand Down
39 changes: 26 additions & 13 deletions apps/web/app/api/voice/transcribe/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,33 @@
*
* Request format from the Muse docs: multipart with a JSON "request" part (no filename) and an
* "audio" part; Authorization: Bearer <key>; model muse-voice-transcribe-1.0.
*
* It spends money, so it is guarded (lib/voice-guard.ts): an off switch, this site's pages only, the Vercel firewall
* rule `voice-transcribe` (5 a minute per visitor, set in the dashboard), and a daily cap.
*/

import { checkRateLimit } from "@vercel/firewall";
import { clientKey, DailyCap, dailyCapFrom, sameSite, voiceDisabled, WindowLimit } from "@/lib/voice-guard";

const MODEL = "muse-voice-transcribe-1.0";
const MAX_BYTES = 1_500_000; // about 30s of 24 kHz mono; the recorder stops at 15s
const LIMIT = 10; // requests per minute per address, to protect the key
const WINDOW_MS = 60_000;
const ON_VERCEL = process.env.VERCEL === "1";
const fallbackLimit = new WindowLimit(5, 60_000); // if the firewall rule can't be checked
const dailyCap = new DailyCap(dailyCapFrom(process.env));

const recent = new Map<string, number[]>();

function rateLimited(address: string) {
const now = Date.now();
const hits = (recent.get(address) ?? []).filter((t) => now - t < WINDOW_MS);
hits.push(now);
recent.set(address, hits);
if (recent.size > 5000) recent.clear(); // keep memory bounded
return hits.length > LIMIT;
/** The Vercel firewall rule decides when it can; otherwise this instance's own count does. */
async function rateLimited(request: Request): Promise<boolean> {
if (ON_VERCEL) {
try {
const { rateLimited, error } = await checkRateLimit("voice-transcribe", { request, timeout: 1_500 });
if (!error) return rateLimited;
if (error === "blocked") return true;
// "not-found": the rule isn't set up in the dashboard yet; fall through to the local count
} catch {
// the firewall couldn't be asked: fall through rather than refuse everyone
}
}
return fallbackLimit.over(clientKey(request.headers, ON_VERCEL));
}

// The documented host. META_MUSE_BASE_URL in .env points at a different service, which doesn't
Expand Down Expand Up @@ -51,14 +62,16 @@ function readTranscript(data: unknown): string {
}

export async function POST(request: Request) {
if (voiceDisabled(process.env)) return Response.json({ error: "disabled" }, { status: 503 });
const key = process.env.META_MUSE_API_KEY;
if (!key) return Response.json({ error: "not_configured" }, { status: 503 });
if (!sameSite(request.headers)) return Response.json({ error: "forbidden" }, { status: 403 });

if (!request.headers.get("content-type")?.startsWith("audio/wav")) {
return Response.json({ error: "wrong_format" }, { status: 415 });
}
const address = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "local";
if (rateLimited(address)) return Response.json({ error: "rate_limited" }, { status: 429 });
if (await rateLimited(request)) return Response.json({ error: "rate_limited" }, { status: 429 });
if (dailyCap.reached()) return Response.json({ error: "daily_limit" }, { status: 429 });

const audio = await request.arrayBuffer();
if (audio.byteLength > MAX_BYTES) return Response.json({ error: "too_long" }, { status: 413 });
Expand Down
42 changes: 42 additions & 0 deletions apps/web/e2e/lib.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { en } from "../i18n/dictionaries/en";
import { brailleCells, matrixDots, wordWidth } from "../lib/braille";
import { oncePerPass } from "../lib/once-per-pass";
import { PIXEL_GRID, PIXEL_ICON_NAMES, pixelRects } from "../lib/pixel-icons";
import { clientKey, DailyCap, dailyCapFrom, sameSite, voiceDisabled, WindowLimit } from "../lib/voice-guard";

/** Pure logic and content checks: no browser needed. */

Expand Down Expand Up @@ -78,3 +79,44 @@ test.describe("story script", () => {
times.slice(1).forEach((t, i) => expect(t - times[i], `gap before line ${i + 2}`).toBeGreaterThanOrEqual(0.6));
});
});

test.describe("voice route guards", () => {
test("the off switch", () => {
expect(voiceDisabled({ VOICE_DEMO: "off" })).toBe(true);
expect(voiceDisabled({ VOICE_DEMO: "OFF" })).toBe(true);
expect(voiceDisabled({})).toBe(false);
expect(voiceDisabled({ VOICE_DEMO: "on" })).toBe(false);
});

test("only this site's own pages may call it", () => {
const h = (o: Record<string, string>) => new Headers(o);
expect(sameSite(h({ origin: "https://recall.example", host: "recall.example" }))).toBe(true);
expect(sameSite(h({ origin: "https://evil.example", host: "recall.example" }))).toBe(false);
expect(sameSite(h({ host: "recall.example" }))).toBe(false); // scripts often send no Origin
expect(sameSite(h({ origin: "not a url", host: "recall.example" }))).toBe(false);
});

test("the client is never taken from X-Forwarded-For, which the client can write", () => {
const spoofed = new Headers({ "x-forwarded-for": "1.2.3.4", "x-real-ip": "203.0.113.9" });
expect(clientKey(spoofed, true)).toBe("203.0.113.9");
expect(clientKey(spoofed, false)).toBe("local");
expect(clientKey(new Headers({ "x-forwarded-for": "1.2.3.4" }), true)).toBe("local");
});

test("the fallback limit counts per client per window", () => {
const limit = new WindowLimit(2, 60_000);
expect([limit.over("a", 0), limit.over("a", 1), limit.over("a", 2)]).toEqual([false, false, true]);
expect(limit.over("b", 3)).toBe(false); // someone else
expect(limit.over("a", 61_000)).toBe(false); // a minute later
});

test("the daily cap stops at the cap and starts again the next UTC day", () => {
const cap = new DailyCap(2);
const d1 = new Date("2026-11-01T10:00:00Z");
expect([cap.reached(d1), cap.reached(d1), cap.reached(d1)]).toEqual([false, false, true]);
expect(cap.reached(new Date("2026-11-02T00:00:01Z"))).toBe(false);
expect(dailyCapFrom({ VOICE_DAILY_CAP: "50" })).toBe(50);
expect(dailyCapFrom({ VOICE_DAILY_CAP: "nonsense" })).toBe(300);
expect(dailyCapFrom({})).toBe(300);
});
});
24 changes: 19 additions & 5 deletions apps/web/e2e/site.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,20 +90,34 @@ test.describe("privacy note", () => {
});

test.describe("voice API", () => {
test("rejects anything that isn't a WAV", async ({ request }) => {
const res = await request.post("/api/voice/transcribe", { headers: { "content-type": "text/plain" }, data: "hello" });
// a browser always sends Origin on a POST; the route answers only this site's own pages
const own = (baseURL?: string) => new URL(baseURL ?? "http://localhost:3000").origin;

test("refuses calls that don't come from this site's pages", async ({ request, baseURL }) => {
const wav = { "content-type": "audio/wav" };
for (const headers of [wav, { ...wav, origin: "https://elsewhere.example" }]) {
const res = await request.post("/api/voice/transcribe", { headers, data: Buffer.alloc(4000) });
expect(res.status()).toBe(403);
expect(await res.json()).toEqual({ error: "forbidden" });
}
const ok = await request.post("/api/voice/transcribe", { headers: { ...wav, origin: own(baseURL) }, data: Buffer.alloc(100) });
expect(ok.status()).toBe(400); // past the check: too short, and no speech service was called
});

test("rejects anything that isn't a WAV", async ({ request, baseURL }) => {
const res = await request.post("/api/voice/transcribe", { headers: { "content-type": "text/plain", origin: own(baseURL) }, data: "hello" });
expect(res.status()).toBe(415);
expect(await res.json()).toEqual({ error: "wrong_format" });
});

test("rejects an empty recording without calling the speech service", async ({ request }) => {
const res = await request.post("/api/voice/transcribe", { headers: { "content-type": "audio/wav" }, data: Buffer.alloc(100) });
test("rejects an empty recording without calling the speech service", async ({ request, baseURL }) => {
const res = await request.post("/api/voice/transcribe", { headers: { "content-type": "audio/wav", origin: own(baseURL) }, data: Buffer.alloc(100) });
expect(res.status()).toBe(400);
expect(await res.json()).toEqual({ error: "too_short" });
});

test("every error code it can return has a message for visitors", () => {
for (const code of ["not_configured", "wrong_format", "rate_limited", "too_long", "too_short", "unreachable", "failed"]) {
for (const code of ["disabled", "not_configured", "forbidden", "wrong_format", "rate_limited", "daily_limit", "too_long", "too_short", "unreachable", "failed"]) {
expect(en.ask.errors[code as keyof typeof en.ask.errors]).toBeTruthy();
}
});
Expand Down
3 changes: 3 additions & 0 deletions apps/web/i18n/dictionaries/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ export const en = {
errors: {
microphone: "The microphone isn’t available. Check this site’s microphone permission.",
not_configured: "Voice questions aren’t set up on this server yet.",
disabled: "Asking by voice is paused for now. Pick a question above instead.",
forbidden: "Voice questions only work from this page.",
daily_limit: "Today’s voice questions are used up. Pick a question above, or try again tomorrow.",
wrong_format: "That recording couldn’t be sent. Try again.",
rate_limited: "Too many questions at once. Try again in a minute.",
too_long: "That recording is too long.",
Expand Down
76 changes: 76 additions & 0 deletions apps/web/lib/voice-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* Guards for the voice demo route, the only route on the site that spends money (Muse Voice Transcribe on the Meta
* Model API). Kept apart from the route so each rule is tested on its own (e2e/lib.spec.ts).
*
* The hard stops are the Vercel firewall rule (`voice-transcribe`, checked with @vercel/firewall in production) and
* the off switch. The in-memory limits are a fallback: each serverless instance counts on its own, so across
* instances they are approximate.
*/

/** VOICE_DEMO=off turns the voice demo off at once, without a deploy. */
export function voiceDisabled(env: Record<string, string | undefined>): boolean {
return (env.VOICE_DEMO ?? "").toLowerCase() === "off";
}

/** Only this site's own pages may call the route: a browser always sends Origin on a POST. */
export function sameSite(headers: Headers): boolean {
const origin = headers.get("origin");
const host = headers.get("x-forwarded-host") ?? headers.get("host");
if (!origin || !host) return false;
try {
return new URL(origin).host === host;
} catch {
return false;
}
}

/**
* Who is asking, for the fallback limit. On Vercel `x-real-ip` is the client's address as Vercel saw it (correct
* once no proxy sits in front); X-Forwarded-For is never used, because a client can write its first entry.
*/
export function clientKey(headers: Headers, onVercel: boolean): string {
return (onVercel && headers.get("x-real-ip")) || "local";
}

/** Requests per window per client, in this instance's memory. */
export class WindowLimit {
private readonly hits = new Map<string, number[]>();
constructor(
private readonly limit: number,
private readonly windowMs: number,
) {}

/** Counts this request; true when it is over the limit. */
over(key: string, now = Date.now()): boolean {
const recent = (this.hits.get(key) ?? []).filter((t) => now - t < this.windowMs);
recent.push(now);
this.hits.set(key, recent);
if (this.hits.size > 5000) this.hits.clear(); // keep memory bounded
return recent.length > this.limit;
}
}

/** Requests per UTC day, for the whole instance: a ceiling on what a bot can spend. */
export class DailyCap {
private day = "";
private count = 0;
constructor(private readonly cap: number) {}

/** Counts this request; true when today's cap is already used up. */
reached(now = new Date()): boolean {
const day = now.toISOString().slice(0, 10);
if (day !== this.day) {
this.day = day;
this.count = 0;
}
if (this.count >= this.cap) return true;
this.count += 1;
return false;
}
}

/** VOICE_DAILY_CAP, or 300 a day. */
export function dailyCapFrom(env: Record<string, string | undefined>): number {
const n = Number(env.VOICE_DAILY_CAP);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 300;
}
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"dependencies": {
"@gsap/react": "^2.1.2",
"@react-three/fiber": "9.8.2",
"@vercel/firewall": "1.2.6",
"@vercel/speed-insights": "^2.0.0",
"gsap": "^3.15.0",
"motion": "^14.7.1",
Expand Down
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading