Compare commits

..

No commits in common. "df34337b4ac5f7291fe329ea78b31a1d3055bf8c" and "aac6befb0d2f9f5d8786e5c72264956f8d39cb47" have entirely different histories.

45 changed files with 94 additions and 863 deletions

View file

@ -454,31 +454,10 @@ client IP with a sliding window. Everything is configurable in `.env.local`:
| `GET/POST/DELETE /api/push`, `POST /api/push/test` | 30 per 5 minutes | `RATE_LIMIT_PUSH` |
| window for all of them | 5 minutes | `RATE_LIMIT_WINDOW_MS` |
On top of the per-route windows there is one **account-level guard**, so no flood — from one client
or from many — can get the Ollama key throttled or banned:
| Guard | Default | Env var |
| --- | --- | --- |
| all model calls together (generate, homework, refine, chat, extract, grade) | 60 per window | `RATE_LIMIT_MODEL_TOTAL` |
| model calls in flight per client IP | 3 | `RATE_LIMIT_CONCURRENT` |
| model calls in flight across all clients | 6 | `RATE_LIMIT_CONCURRENT_TOTAL` |
A request over the concurrency cap gets a **429 `kind: "busy"`** (“Freezmo is already working on
something — try again in a moment”), not a punishment: the slot frees as soon as the model call
finishes, and the client's Retry button works. Slots are taken *after* every cheap rejection, so a
malformed request can never hold one.
Two more disk guards: `REPORTS_MAX_BYTES` (default 8 MB) stops appending to `reports.jsonl` once it
is full, and `PUSH_MAX_SUBSCRIPTIONS` (default 50) refuses new reminder devices over the cap.
Reminder endpoints must also be a real push service — `fcm.googleapis.com`,
`updates.push.services.mozilla.com`, `*.push.apple.com`, `*.notify.windows.com` — or a host you add
with `PUSH_ALLOWED_HOSTS`, because the server POSTs to whatever is stored.
When a limit is hit the route answers **429** with a `Retry-After` header and a friendly message
(“Too many requests — try again in 42s”); the UI shows that message and keeps the Retry button. The
client IP is taken from the **last** `x-forwarded-for` hop (the one your reverse proxy appended) or
from `x-real-ip`; taking the first hop would let a client spoof its own identity and dodge every
per-IP limit. Requests without those headers share one bucket.
client IP is taken from `x-forwarded-for` / `x-real-ip` when present (reverse proxy), otherwise all
local clients share one bucket.
Caveats, stated honestly: counters live **in memory**, so they reset on restart and are not shared
between multiple instances or Node workers. That is fine for a self-hosted single-instance app; if
@ -679,19 +658,6 @@ self-hosted study app with no accounts, no database and no server-side state.
parsing, on top of the zod limits on image count, text length and field sizes.
- **Rate limiting** per client IP; see [Rate limits](#rate-limits). Auth is checked *before* parsing
request bodies, so unauthenticated traffic costs almost nothing.
- **An account-level model budget and concurrency caps** sit in front of every route that spends an
Ollama call (`lib/model-guard.ts`): 60 calls per window across all clients, 3 in flight per IP, 6
in flight in total. A flood cannot pile up parallel calls or burn the account — the thing that
gets a key throttled or banned. Slots are released when the call finishes, including a client that
disconnects mid-stream.
- **Images must be base64 data URLs** (`dataUrlSchema`). A plain URL in an `images` field would make
Ollama fetch an arbitrary address on our behalf, so the shape is part of the contract.
- **Reminder endpoints must be real push services** (an allowlist, `PUSH_ALLOWED_HOSTS`): the server
POSTs to whatever is stored, so an arbitrary URL there would be an SSRF primitive.
- **Security headers** on every response: `X-Frame-Options: DENY`, `Content-Security-Policy:
frame-ancestors 'none'`, `X-Content-Type-Options: nosniff`, a referrer policy and a
Permissions-Policy that turns off camera/microphone/geolocation/payment (the camera button is a
file input with `capture`, which is not gated by that policy).
- **No CSRF surface**: no cookies, no sessions, no server-side mutations.
- **Prompt injection is low impact by design.** Your notes could contain text that tries to
manipulate the model, but the model can only return a JSON deck (validated per activity, dropped if
@ -704,10 +670,8 @@ self-hosted study app with no accounts, no database and no server-side state.
notes.
- Anything stored in `localStorage` is readable by JavaScript on the same origin. Freezmo has no XSS
vector today, but that is the standard risk of browser-stored credentials.
- Rate limiting keeps counters in memory: single instance only, reset on restart. The client IP is
read from the **last** `x-forwarded-for` hop, so a client cannot spoof its identity — but behind
more than one proxy the "last hop" is the proxy closest to Freezmo, which means those clients
share one bucket. Point `x-real-ip` at the real client if you need per-client buckets there.
- Rate limiting trusts `x-forwarded-for` when present (correct behind a proxy, spoofable when
exposed directly) and keeps counters in memory: single instance only, reset on restart.
- Ollama cloud usage is billed to whoever's key is used, so treat a shared Freezmo URL as shared key
access in `server` mode.
- No accounts means no per-user isolation: anyone who can reach the URL can use the app (and, in

View file

@ -5,8 +5,6 @@ import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth } from "@
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
import { acquireModelGate } from "@/lib/model-guard";
import { dataUrlSchema } from "@/lib/schemas";
import { translate } from "@/lib/i18n-dict";
export const runtime = "nodejs";
@ -18,7 +16,7 @@ const MAX_BODY_BYTES = 33554432;
const MAX_IMAGE_CHARS = 8_000_000;
const requestSchema = z.object({
images: z.array(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(),
images: z.array(z.string().min(32).max(MAX_IMAGE_CHARS)).max(16).optional(),
text: z.string().max(120_000).optional(),
});
@ -68,8 +66,6 @@ export async function POST(request: Request) {
return Response.json({ error: translate(locale, "server.needContent") }, { status: 400 });
}
const gate = acquireModelGate(request, config, locale);
if ("response" in gate) return gate.response;
const abort = new AbortController();
const timer = setTimeout(() => abort.abort(), config.timeoutMs);
try {
@ -101,7 +97,6 @@ export async function POST(request: Request) {
);
} finally {
clearTimeout(timer);
gate.gate.release();
}
}

View file

@ -4,7 +4,6 @@ import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveM
import { friendlyErrorMessage, ollamaChatStream, OllamaError } from "@/lib/ollama";
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
import { acquireModelGate } from "@/lib/model-guard";
import { translate } from "@/lib/i18n-dict";
import { buildChatSystemPrompt } from "@/lib/prompts";
import { estimateTokens, trimHistory } from "@/lib/chat-trim";
@ -132,11 +131,6 @@ export async function POST(request: Request) {
})),
});
// Concurrency + the account-wide model budget (lib/model-guard), taken
// after every cheap rejection so a 400 can never leak a slot.
const gate = acquireModelGate(request, config, locale);
if ("response" in gate) return gate.response;
const encode = encoder();
const abort = new AbortController();
const globalTimer = setTimeout(() => abort.abort(), config.timeoutMs);
@ -176,7 +170,6 @@ export async function POST(request: Request) {
}
} finally {
clearTimeout(globalTimer);
gate.gate.release();
try {
controller.close();
} catch {
@ -186,7 +179,6 @@ export async function POST(request: Request) {
},
cancel() {
clearTimeout(globalTimer);
gate.gate.release();
abort.abort();
},
});

View file

@ -5,9 +5,8 @@ import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveM
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
import { acquireModelGate } from "@/lib/model-guard";
import { translate } from "@/lib/i18n-dict";
import { dataUrlSchema, difficultySchema } from "@/lib/schemas";
import { difficultySchema } from "@/lib/schemas";
import { uid } from "@/lib/utils";
import { xpForActivity } from "@/lib/xp";
@ -19,7 +18,7 @@ export const maxDuration = 300;
const MAX_IMAGE_CHARS = 8_000_000;
const requestSchema = z.object({
images: z.array(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(),
images: z.array(z.string().min(32).max(MAX_IMAGE_CHARS)).max(16).optional(),
text: z.string().max(120_000).optional(),
difficulty: difficultySchema.default("medium"),
count: z.number().int().min(4).max(18).optional(),
@ -102,11 +101,6 @@ export async function POST(request: Request) {
);
}
// Concurrency + the account-wide model budget (lib/model-guard). Taken
// only now, after every cheap rejection, so a 400 can never leak a slot.
const gate = acquireModelGate(request, config, locale);
if ("response" in gate) return gate.response;
const requestedCount = count ?? config.defaultActivityCount;
const encode = encoder();
const abort = new AbortController();
@ -207,7 +201,6 @@ export async function POST(request: Request) {
});
} finally {
clearTimeout(globalTimer);
gate.gate.release();
try {
controller.close();
} catch {
@ -217,7 +210,6 @@ export async function POST(request: Request) {
},
cancel() {
clearTimeout(globalTimer);
gate.gate.release();
abort.abort();
},
});

View file

@ -5,7 +5,6 @@ import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth } from "@
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
import { acquireModelGate } from "@/lib/model-guard";
import { translate } from "@/lib/i18n-dict";
export const runtime = "nodejs";
@ -55,8 +54,6 @@ export async function POST(request: Request) {
return Response.json({ error: translate(locale, "server.invalidGrade") }, { status: 400 });
}
const gate = acquireModelGate(request, config, locale);
if ("response" in gate) return gate.response;
try {
const result = await gradeShortAnswer({
model: config.textModel,
@ -75,7 +72,5 @@ export async function POST(request: Request) {
},
{ status: 502 },
);
} finally {
gate.gate.release();
}
}

View file

@ -6,7 +6,6 @@ import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
import { translate } from "@/lib/i18n-dict";
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
import { acquireModelGate } from "@/lib/model-guard";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@ -65,8 +64,6 @@ export async function POST(request: Request) {
);
}
const gate = acquireModelGate(request, config, locale);
if ("response" in gate) return gate.response;
try {
const text = await refineHomeworkText({
model: config.textModel,
@ -89,7 +86,5 @@ export async function POST(request: Request) {
},
{ status: 502 },
);
} finally {
gate.gate.release();
}
}

View file

@ -5,9 +5,8 @@ import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveM
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
import { acquireModelGate } from "@/lib/model-guard";
import { translate } from "@/lib/i18n-dict";
import { dataUrlSchema, homeworkModeSchema, type HomeworkStep } from "@/lib/schemas";
import { homeworkModeSchema, type HomeworkStep } from "@/lib/schemas";
import { uid } from "@/lib/utils";
export const runtime = "nodejs";
@ -21,12 +20,12 @@ const MAX_BODY_BYTES = 33554432;
const requestSchema = z.object({
mode: homeworkModeSchema,
images: z.array(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(),
images: z.array(z.string().min(32).max(MAX_IMAGE_CHARS)).max(16).optional(),
text: z.string().max(120_000).optional(),
/** Mode "check": the student's own attempt at the assignment. */
solution: z.string().max(120_000).optional(),
/** Mode "check": photos of the attempt (handwritten work, scanned pages). */
solutionImages: z.array(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(),
solutionImages: z.array(z.string().min(32).max(MAX_IMAGE_CHARS)).max(16).optional(),
/** Language of the assignment (usually detected by the vision step). */
language: z.string().max(60).optional(),
});
@ -96,11 +95,6 @@ export async function POST(request: Request) {
return Response.json({ error: translate(locale, "server.needSolution") }, { status: 400 });
}
// Concurrency + the account-wide model budget (lib/model-guard), taken
// after every cheap rejection so a 400 can never leak a slot.
const gate = acquireModelGate(request, config, locale);
if ("response" in gate) return gate.response;
const encode = encoder();
const abort = new AbortController();
// A single hard deadline for the whole pipeline (two sequential model calls).
@ -263,7 +257,6 @@ export async function POST(request: Request) {
});
} finally {
clearTimeout(globalTimer);
gate.gate.release();
try {
controller.close();
} catch {
@ -273,7 +266,6 @@ export async function POST(request: Request) {
},
cancel() {
clearTimeout(globalTimer);
gate.gate.release();
abort.abort();
},
});

View file

@ -16,23 +16,6 @@ export const dynamic = "force-dynamic";
* is false — no half-configured UI ever shows.
*/
/**
* The server POSTs to the stored endpoint with VAPID headers, so it must be a
* real push service: an arbitrary URL here would be an SSRF primitive. The
* allowed hosts come from the config (PUSH_ALLOWED_HOSTS), defaulting to the
* real services.
*/
function isPushEndpoint(value: string, allowedHosts: string[]): boolean {
try {
const url = new URL(value);
if (url.protocol !== "https:") return false;
const host = url.hostname.toLowerCase();
return allowedHosts.some((entry) => (entry.startsWith(".") ? host.endsWith(entry) : host === entry));
} catch {
return false;
}
}
const subscriptionSchema = z.object({
endpoint: z.string().url().max(2000),
keys: z.object({
@ -113,21 +96,9 @@ export async function POST(request: Request) {
{ status: 400 },
);
}
if (!isPushEndpoint(parsed.data.endpoint, config.push.allowedHosts)) {
return Response.json(
{ error: translate(locale, "server.pushEndpoint"), kind: "bad_endpoint" },
{ status: 400 },
);
}
const count = await addSubscription(config.push.dir, parsed.data, config.push.maxSubscriptions);
if (!count.accepted) {
return Response.json(
{ error: translate(locale, "server.pushTooMany", { max: config.push.maxSubscriptions }), kind: "too_many" },
{ status: 429 },
);
}
return Response.json({ ok: true, count: count.count }, { headers: { "Cache-Control": "no-store" } });
const count = await addSubscription(config.push.dir, parsed.data);
return Response.json({ ok: true, count }, { headers: { "Cache-Control": "no-store" } });
}
export async function DELETE(request: Request) {

View file

@ -51,16 +51,7 @@ export async function POST(request: Request) {
const line = JSON.stringify({ ...parsed.data, receivedAt: new Date().toISOString() });
try {
await fs.mkdir(config.reports.dir, { recursive: true });
const file = path.join(config.reports.dir, "reports.jsonl");
/* Disk guard: reports are a convenience for the operator, so once the
file passes the cap the app stops appending (the browser's own list is
still the source of truth for skipping). */
const size = await fs.stat(file).then((stats) => stats.size).catch(() => 0);
if (size >= config.reports.maxBytes) {
console.warn("[api/reports] reports.jsonl is at its size cap — not appending");
return Response.json({ ok: true });
}
await fs.appendFile(file, `${line}\n`, "utf8");
await fs.appendFile(path.join(config.reports.dir, "reports.jsonl"), `${line}\n`, "utf8");
} catch {
return Response.json({ error: "could not store the report" }, { status: 500 });
}

View file

@ -72,10 +72,7 @@ export default function DeckPage() {
return (
<ApiKeyGate>
{/* The feed is a fixed overlay, but its content still needs a landmark. */}
<main>
<StudyFeed key={deck.id} deck={deck} />
</main>
</ApiKeyGate>
);
}

View file

@ -647,28 +647,6 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar) {
background: var(--hover);
color: var(--text);
}
/* The rail's two creation actions ("New deck", "New homework") are buttons,
not nav rows: once the library is full the empty-state CTAs are long gone,
and on desktop the rail is the only place left to start something. */
.nav-action {
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
border-radius: 0.75rem;
padding: 0.5rem 0.625rem;
font-size: 13.5px;
font-weight: 600;
color: var(--text);
background: var(--surface-strong);
border: 1px solid var(--glass-border);
transition: background-color 160ms ease, border-color 160ms ease;
}
.nav-action:hover {
background: var(--hover);
border-color: var(--nav-active-border);
}
.nav-row[aria-current="page"],
.nav-row[aria-pressed="true"] {
background: var(--nav-active-bg);

View file

@ -55,7 +55,7 @@ export default function manifest(): MetadataRoute.Manifest {
],
shortcuts: [
{ name: "New deck", short_name: "New", url: "/create" },
{ name: "New homework", short_name: "Homework", url: "/homework" },
{ name: "Make my homework", short_name: "Homework", url: "/homework" },
{ name: "Library", short_name: "Library", url: "/" },
],
/* Share text and links from other apps (GET). Files are POSTed to the

View file

@ -78,8 +78,6 @@ export function DataSection() {
<input
ref={fileRef}
type="file"
aria-hidden="true"
tabIndex={-1}
accept="application/json,.json"
className="hidden"
onChange={(event) => void pick(event.target.files?.[0])}

View file

@ -182,12 +182,12 @@ export function DeckCard({
</Button>
</div>
) : (
<h2
<h3
className="line-clamp-2 text-[15px] font-semibold leading-snug tracking-[-0.01em] text-[var(--text)]"
title={deck.title}
>
{truncate(deck.title, 90)}
</h2>
</h3>
)}
{/* Phones: one compact meta line. */}
<p className="mt-1 text-[12.5px] text-[var(--text-subtle)] sm:hidden">

View file

@ -62,12 +62,12 @@ export function HomeworkCard({
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<h2
<h3
className="line-clamp-2 text-[15px] font-semibold leading-snug tracking-[-0.01em] text-[var(--text)]"
title={record.title}
>
{truncate(record.title, 90)}
</h2>
</h3>
<p className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[12.5px] text-[var(--text-subtle)]">
<span className="inline-flex items-center gap-1">
{record.mode === "make" ? <BoltIcon className="size-3.5" /> : <PlayIcon className="size-3.5" />}

View file

@ -25,12 +25,7 @@ function urlBase64ToUint8Array(base64: string): Uint8Array {
* the server has VAPID keys configured. Without them the reminder block is
* hidden completely, exactly as if the feature did not exist.
*/
/**
* `level` keeps the heading order right in both places it is used: h2 on the
* library page (under its h1) and h3 inside the settings dialog (under its h2
* title).
*/
export function LearningSection({ level = 2 }: { level?: 2 | 3 } = {}) {
export function LearningSection() {
const { t } = useI18n();
const dailyGoal = useSyncExternalStore(
settingsStore.subscribe,
@ -159,11 +154,7 @@ export function LearningSection({ level = 2 }: { level?: 2 | 3 } = {}) {
return (
<section className="mb-4 space-y-3 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
{level === 3 ? (
<h3 className="text-sm font-semibold text-frost-950 dark:text-frost-50">{t("settings.learning")}</h3>
) : (
<h2 className="text-sm font-semibold text-frost-950 dark:text-frost-50">{t("settings.learning")}</h2>
)}
<div className="space-y-1.5">
<p className="text-[12px] font-semibold text-[var(--text)]">{t("settings.dailyGoal")}</p>

View file

@ -220,8 +220,6 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
<input
ref={importRef}
type="file"
aria-hidden="true"
tabIndex={-1}
accept="application/json,.json"
className="sr-only"
onChange={(event) => {

View file

@ -3,31 +3,22 @@
import { haptic } from "@/lib/haptics";
import { useI18n } from "@/lib/i18n";
import { reportQuestion, unreportQuestion } from "@/lib/question-reports";
import type { Activity } from "@/lib/schemas";
import type { Activity, Deck } from "@/lib/schemas";
import { showToast } from "@/lib/toast";
/**
* The red "!" report control that sits under the card (bottom-right of the
* exercise): records the question, sends the operator a copy and — in the
* study feeds — skips past it. Undo lives in the toast. 44px, because it is a
* thumb target.
*
* `deckId` is a deck id in the feeds and the homework record's id for a
* homework exercise; `onSkip` is omitted where there is no queue to skip.
* exercise): records the question, sends the operator a copy and skips past
* it. Undo lives in the toast. 44px, because it is a thumb target.
*/
export function ReportQuestionButton({
deckId,
deckTitle,
deck,
activity,
onSkip,
labelKey = "feed.report",
}: {
deckId: string;
deckTitle?: string;
deck: Deck;
activity: Activity;
onSkip?: () => void;
/** i18n key for the accessible name ("Report question" / "Report exercise"). */
labelKey?: string;
onSkip: () => void;
}) {
const { t } = useI18n();
@ -36,16 +27,16 @@ export function ReportQuestionButton({
type="button"
onClick={() => {
haptic("tap");
reportQuestion({ deckId, deckTitle, activity });
reportQuestion({ deck, activity });
showToast({
message: t("feed.reported"),
actionLabel: t("toast.undo"),
onAction: () => unreportQuestion(deckId, activity.id),
onAction: () => unreportQuestion(deck.id, activity.id),
});
onSkip?.();
onSkip();
}}
aria-label={t(labelKey)}
title={t(labelKey)}
aria-label={t("feed.report")}
title={t("feed.report")}
className="grid size-11 shrink-0 place-items-center rounded-xl border-2 border-rose-400/80 text-base font-black leading-none text-rose-600 transition hover:bg-rose-50 dark:border-rose-400/50 dark:text-rose-300 dark:hover:bg-rose-400/10"
>
<span aria-hidden>!</span>

View file

@ -81,8 +81,6 @@ export function ReviewClient({ mistakes, deckId }: { mistakes: boolean; deckId?:
const sessionItems: SessionItem[] = session.map(({ deck, activity }) => ({ deck, activity }));
return (
// The feed is a fixed overlay, but its content still needs a landmark.
<main>
<SessionFeed
title={title}
items={sessionItems}
@ -114,6 +112,5 @@ export function ReviewClient({ mistakes, deckId }: { mistakes: boolean; deckId?:
</div>
)}
/>
</main>
);
}

View file

@ -221,8 +221,7 @@ export function SessionFeed({
<AiDisclaimer />
<div className="flex justify-end">
<ReportQuestionButton
deckId={item.deck.id}
deckTitle={item.deck.title}
deck={item.deck}
activity={item.activity}
onSkip={() => {
setSkipped((current) => [...current, item.activity.id]);

View file

@ -407,7 +407,7 @@ export function SettingsDialog({
</section>
{/* ------------------------------------------- data & sync */}
<LearningSection level={3} />
<LearningSection />
<DataSection />
<SyncSection />
@ -746,7 +746,7 @@ export function SettingsDialog({
</section>
{/* ------------------------------------------- data & sync */}
<LearningSection level={3} />
<LearningSection />
<DataSection />
<SyncSection />

View file

@ -137,24 +137,23 @@ function NavContent({ collapsed = false }: { collapsed?: boolean }) {
{!collapsed ? <span className="flex-1 truncate text-left">{t("sidebar.collapse")}</span> : null}
</button>
{/* new deck: a button, not a nav row — it stays visible when the
library is already full (see .nav-action in globals.css) --------- */}
{/* new deck: a quiet row, not a competing primary button ---------- */}
<Link
href="/create"
className={cn("nav-action mb-2.5", collapsed && "justify-center px-2")}
className={cn("nav-row mb-3", collapsed && "justify-center px-2")}
title={t("common.newDeck")}
>
<PlusIcon className="size-4 shrink-0 text-accent-600 dark:text-accent-200" />
<PlusIcon className="size-4 shrink-0" />
{!collapsed ? <span className="flex-1 truncate text-left">{t("common.newDeck")}</span> : null}
</Link>
{/* new homework: the same button ------------------------------------ */}
{/* make my homework: same quiet row --------------------------------- */}
<Link
href="/homework"
className={cn("nav-action mb-3", collapsed && "justify-center px-2")}
className={cn("nav-row mb-3", collapsed && "justify-center px-2")}
title={t("homework.nav")}
>
<SparklesIcon className="size-4 shrink-0 text-accent-600 dark:text-accent-200" />
<SparklesIcon className="size-4 shrink-0" />
{!collapsed ? <span className="flex-1 truncate text-left">{t("homework.nav")}</span> : null}
</Link>

View file

@ -332,8 +332,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
<AiDisclaimer />
<div className="flex justify-end">
<ReportQuestionButton
deckId={currentDeck.id}
deckTitle={currentDeck.title}
deck={currentDeck}
activity={activity}
onSkip={() => {
setSkipped((current) => [...current, activity.id]);

View file

@ -650,8 +650,6 @@ export function UploadScreen() {
<input
ref={fileInputRef}
type="file"
aria-hidden="true"
tabIndex={-1}
accept={`${ACCEPTED_TYPES.join(",")},${DOCUMENT_ACCEPT_ATTR}`}
multiple
className="sr-only"
@ -665,8 +663,6 @@ export function UploadScreen() {
<input
ref={cameraInputRef}
type="file"
aria-hidden="true"
tabIndex={-1}
accept="image/*"
capture="environment"
className="sr-only"
@ -1132,8 +1128,6 @@ export function UploadScreen() {
<input
ref={fileInputRef}
type="file"
aria-hidden="true"
tabIndex={-1}
accept={`${ACCEPTED_TYPES.join(",")},${DOCUMENT_ACCEPT_ATTR}`}
multiple
className="sr-only"
@ -1145,8 +1139,6 @@ export function UploadScreen() {
<input
ref={cameraInputRef}
type="file"
aria-hidden="true"
tabIndex={-1}
accept="image/*"
capture="environment"
className="sr-only"

View file

@ -105,8 +105,6 @@ export function Composer({
<input
ref={fileInput}
type="file"
aria-hidden="true"
tabIndex={-1}
accept="image/*,application/pdf,.docx,.pptx,.odt,.txt,.md,.csv"
className="sr-only"
onChange={(event) => {

View file

@ -6,7 +6,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActivityRenderer } from "@/components/activities";
import { PromptCard, QuestionText } from "@/components/activities/shared";
import { AiDisclaimer } from "@/components/AiDisclaimer";
import { ReportQuestionButton } from "@/components/ReportQuestionButton";
import { XIcon } from "@/components/icons";
import { Button, ProgressBar } from "@/components/ui";
import { AskButton } from "@/components/chat/AskButton";
@ -247,7 +246,7 @@ export function HomeworkGuide({
{/* checkpoint or a plain next button */}
{step.checkpoint ? (
<div className="space-y-3">
<div className="space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
{t("homework.guide.tryIt")}
</p>
@ -264,16 +263,6 @@ export function HomeworkGuide({
}}
onNext={() => goTo(stepIndex + 1)}
/>
{/* Same report control as the study feeds, under the
exercise (the homework record is the "deck" here). */}
<div className="flex justify-end">
<ReportQuestionButton
deckId={record.id}
deckTitle={record.title}
activity={step.checkpoint}
labelKey="homework.report"
/>
</div>
</div>
) : (
<Button className="touch-target self-start" onClick={() => goTo(stepIndex + 1)}>

View file

@ -246,8 +246,6 @@ export function HomeworkInput({
<input
ref={solutionFileRef}
type="file"
aria-hidden="true"
tabIndex={-1}
accept={`${ACCEPTED_TYPES.join(",")},${DOCUMENT_ACCEPT_ATTR}`}
multiple
className="sr-only"
@ -259,8 +257,6 @@ export function HomeworkInput({
<input
ref={solutionCameraRef}
type="file"
aria-hidden="true"
tabIndex={-1}
accept="image/*"
capture="environment"
className="sr-only"
@ -382,8 +378,6 @@ export function HomeworkInput({
<input
ref={fileInputRef}
type="file"
aria-hidden="true"
tabIndex={-1}
accept={`${ACCEPTED_TYPES.join(",")},${DOCUMENT_ACCEPT_ATTR}`}
multiple
className="sr-only"
@ -397,8 +391,6 @@ export function HomeworkInput({
<input
ref={cameraInputRef}
type="file"
aria-hidden="true"
tabIndex={-1}
accept="image/*"
capture="environment"
className="sr-only"
@ -764,8 +756,6 @@ export function HomeworkInput({
<input
ref={fileInputRef}
type="file"
aria-hidden="true"
tabIndex={-1}
accept={`${ACCEPTED_TYPES.join(",")},${DOCUMENT_ACCEPT_ATTR}`}
multiple
className="sr-only"
@ -777,8 +767,6 @@ export function HomeworkInput({
<input
ref={cameraInputRef}
type="file"
aria-hidden="true"
tabIndex={-1}
accept="image/*"
capture="environment"
className="sr-only"

View file

@ -37,12 +37,6 @@ export type ServerConfig = {
push: number;
chat: number;
reports: number;
/** All Ollama calls together per window (account-level guard). */
modelTotal: number;
/** In-flight model calls per client IP. */
concurrent: number;
/** In-flight model calls across all clients. */
concurrentTotal: number;
};
sync: {
/** Where opaque encrypted sync blobs live (SYNC_DIR). */
@ -59,20 +53,10 @@ export type ServerConfig = {
subject: string;
/** Where push subscriptions live (PUSH_DIR). */
dir: string;
/** Cap on stored subscriptions (PUSH_MAX_SUBSCRIPTIONS). */
maxSubscriptions: number;
/**
* Hosts the server is allowed to POST reminders to (PUSH_ALLOWED_HOSTS,
* comma separated). Defaults to the real push services; a self-hosted
* push service can be added, and the test suite points it at its own stub.
*/
allowedHosts: string[];
};
reports: {
/** Where reported questions are appended, one JSON line each (REPORTS_DIR). */
dir: string;
/** Stop appending past this size (REPORTS_MAX_BYTES) — disk guard. */
maxBytes: number;
};
};
@ -172,12 +156,6 @@ export function getServerConfig(): ServerConfig {
chat: positiveInt(process.env.RATE_LIMIT_CHAT, 60),
// Question reports are cheap file appends.
reports: positiveInt(process.env.RATE_LIMIT_REPORTS, 20),
// Every Ollama call, all clients together: the account-level guard. A
// flood would otherwise get the key throttled or banned.
modelTotal: positiveInt(process.env.RATE_LIMIT_MODEL_TOTAL, 60),
// In-flight model calls: per client IP, and across all clients.
concurrent: positiveInt(process.env.RATE_LIMIT_CONCURRENT, 3),
concurrentTotal: positiveInt(process.env.RATE_LIMIT_CONCURRENT_TOTAL, 6),
},
sync: {
dir: process.env.SYNC_DIR?.trim() || path.join(process.cwd(), ".freezmo", "sync"),
@ -189,25 +167,9 @@ export function getServerConfig(): ServerConfig {
privateKey: process.env.VAPID_PRIVATE_KEY?.trim() || "",
subject: process.env.VAPID_SUBJECT?.trim() || "mailto:freezmo@localhost",
dir: process.env.PUSH_DIR?.trim() || path.join(process.cwd(), ".freezmo", "push"),
maxSubscriptions: positiveInt(process.env.PUSH_MAX_SUBSCRIPTIONS, 50),
allowedHosts: (process.env.PUSH_ALLOWED_HOSTS ?? "")
.split(",")
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean)
.concat(
process.env.PUSH_ALLOWED_HOSTS
? []
: [
"fcm.googleapis.com",
"updates.push.services.mozilla.com",
".push.apple.com",
".notify.windows.com",
],
),
},
reports: {
dir: process.env.REPORTS_DIR?.trim() || path.join(process.cwd(), ".freezmo", "reports"),
maxBytes: positiveInt(process.env.REPORTS_MAX_BYTES, 8 * 1024 * 1024),
},
};
}

View file

@ -270,7 +270,7 @@ const en: Record<string, string> = {
"health.createKey.end": "instead and set",
/* make my homework ------------------------------------------------- */
"homework.nav": "New homework",
"homework.nav": "Make my homework",
"wizard.mode.deck": "Study deck",
"wizard.mode.homework": "Make my homework",
"wizard.mode.aria": "Study deck or homework",
@ -317,7 +317,6 @@ const en: Record<string, string> = {
"homework.guide.stepOf": "Step {n} of {total}",
"homework.guide.close": "Close the guide",
"homework.guide.tryIt": "Try it yourself",
"homework.report": "Report exercise",
"homework.guide.hint": "Show a hint",
"homework.guide.hintMore": "Another hint",
"homework.guide.hintAnswer": "Show the answer",
@ -345,7 +344,7 @@ const en: Record<string, string> = {
/* quick actions (library empty state + /create switcher) ----------- */
"emptyStateCreateDeck": "Create study deck",
"emptyStateHomework": "New homework",
"emptyStateHomework": "Make my homework",
/* library --------------------------------------------------------- */
"library.title": "Your icebox",
@ -743,9 +742,6 @@ const en: Record<string, string> = {
"server.invalidOrigin": "Invalid Origin header.",
"server.crossOrigin": "Cross-origin requests are not allowed.",
"server.rateLimited": "Too many requests — this app allows {limit} per window. Try again in {retry}s.",
"server.busy": "Freezmo is already working on something — try again in a moment.",
"server.pushTooMany": "This server already stores {max} reminder devices — remove one first.",
"server.pushEndpoint": "That is not a push service this server is allowed to send to.",
"server.authMissingKey":
"Add your Ollama API key first — open the settings dialog (⚙️) and paste a key from ollama.com/settings/keys.",
"server.authServerKeyMissing":
@ -1213,7 +1209,7 @@ const cs: Record<string, string> = {
"health.createKey.end": "a nastavit",
/* make my homework ------------------------------------------------- */
"homework.nav": "Nový úkol",
"homework.nav": "Udělej mi úkol",
"wizard.mode.deck": "Studijní sada",
"wizard.mode.homework": "Udělej mi úkol",
"wizard.mode.aria": "Studijní sada nebo úkol",
@ -1260,7 +1256,6 @@ const cs: Record<string, string> = {
"homework.guide.stepOf": "Krok {n}/{total}",
"homework.guide.close": "Zavřít návod",
"homework.guide.tryIt": "Zkus si to",
"homework.report": "Nahlásit cvičení",
"homework.guide.hint": "Ukaž nápovědu",
"homework.guide.hintMore": "Další nápověda",
"homework.guide.hintAnswer": "Ukaž odpověď",
@ -1288,7 +1283,7 @@ const cs: Record<string, string> = {
/* quick actions (library empty state + /create switcher) ----------- */
"emptyStateCreateDeck": "Vytvořit studijní balíček",
"emptyStateHomework": "Nový úkol",
"emptyStateHomework": "Udělej mi úkol",
/* library --------------------------------------------------------- */
"library.title": "Tvoje mraznička",
@ -1686,9 +1681,6 @@ const cs: Record<string, string> = {
"server.invalidOrigin": "Neplatná hlavička Origin.",
"server.crossOrigin": "Požadavky z jiné domény nejsou povoleny.",
"server.rateLimited": "Příliš mnoho požadavků — aplikace povoluje {limit} za okno. Zkus to znovu za {retry} s.",
"server.busy": "Freezmo už na něčem pracuje — zkus to za chvilku znovu.",
"server.pushTooMany": "Server už má uložených {max} zařízení pro připomínky — nejdřív nějaké odeber.",
"server.pushEndpoint": "Na tuhle službu server připomínky posílat nesmí.",
"server.authMissingKey":
"Nejdřív přidej svůj API klíč Ollama — otevři nastavení (⚙️) a vlož klíč z ollama.com/settings/keys.",
"server.authServerKeyMissing":

View file

@ -1,60 +0,0 @@
import type { ServerConfig } from "./config";
import { translate, type Locale } from "./i18n-dict";
import { acquireSlot, clientKey, rateLimit, rateLimitResponse, releaseSlot } from "./rate-limit";
/**
* One gate in front of every route that spends an Ollama call:
*
* - a **global budget** per window (all clients together) so no flood can
* burn through the account — the thing that gets a key throttled or banned;
* - a **per-IP concurrency cap** and a **global one**, so a burst of parallel
* requests can't pile up model calls (or sockets and memory).
*
* The slot is released once, when the response is finished — including a
* client that disconnects mid-stream, and including routes that throw.
*/
export type ModelGate = { release: () => void };
export function acquireModelGate(
request: Request,
config: ServerConfig,
locale: Locale,
): { gate: ModelGate } | { response: Response } {
const perIpKey = `model-ip:${clientKey(request)}`;
const totalKey = "model-total";
if (!acquireSlot(totalKey, config.rateLimits.concurrentTotal)) {
return { response: busyResponse(locale) };
}
if (!acquireSlot(perIpKey, config.rateLimits.concurrent)) {
releaseSlot(totalKey);
return { response: busyResponse(locale) };
}
const budget = rateLimit("model-budget", config.rateLimits.modelTotal, config.rateLimits.windowMs);
if (!budget.allowed) {
releaseSlot(perIpKey);
releaseSlot(totalKey);
return { response: rateLimitResponse(budget, locale) };
}
let released = false;
return {
gate: {
release: () => {
if (released) return;
released = true;
releaseSlot(perIpKey);
releaseSlot(totalKey);
},
},
};
}
/** 429 for "something is already running" — not a rate-limit punishment. */
function busyResponse(locale: Locale): Response {
return Response.json(
{ error: translate(locale, "server.busy"), kind: "busy", retryAfter: 5 },
{ status: 429, headers: { "Retry-After": "5", "Cache-Control": "no-store" } },
);
}

View file

@ -46,19 +46,14 @@ async function writeSubscriptions(dir: string, next: PushSubscriptionRecord[]):
export async function addSubscription(
dir: string,
subscription: { endpoint: string; keys: { p256dh: string; auth: string } },
/** Cap on stored subscriptions — the configured, sane one. */
max = 50,
): Promise<{ count: number; accepted: boolean }> {
): Promise<number> {
const current = await readSubscriptions(dir);
const exists = current.some((entry) => entry.endpoint === subscription.endpoint);
const next = current.filter((entry) => entry.endpoint !== subscription.endpoint);
// Refreshing a device that is already subscribed always works; a brand new
// device over the cap is refused.
if (!exists && next.length >= max) return { count: current.length, accepted: false };
next.push({ ...subscription, createdAt: Date.now() });
const capped = next.slice(-max);
// A cap keeps a runaway script from filling the disk.
const capped = next.slice(-2000);
await writeSubscriptions(dir, capped);
return { count: capped.length, accepted: true };
return capped.length;
}
export async function removeSubscription(dir: string, endpoint: string): Promise<number> {

View file

@ -132,15 +132,10 @@ export function withoutReported(deckId: string, activities: Activity[]): Activit
}
/** Adds (or refreshes) a report and sends a copy to the server, best effort. */
export function reportQuestion(input: {
/** A deck id, or the homework record's id for a homework exercise. */
deckId: string;
deckTitle?: string;
activity: Activity;
}): QuestionReport {
export function reportQuestion(input: { deck: { id: string; title: string }; activity: Activity }): QuestionReport {
const entry: QuestionReport = {
deckId: input.deckId,
deckTitle: input.deckTitle,
deckId: input.deck.id,
deckTitle: input.deck.title,
activityId: input.activity.id,
type: input.activity.type,
question: activityText(input.activity).slice(0, 600),

View file

@ -73,53 +73,12 @@ function prune(now: number, windowMs: number): void {
}
}
/* ------------------------------------------------------------------ */
/* In-flight slots (concurrency, not per-window) */
/* ------------------------------------------------------------------ */
/**
* Per-window limits alone let a client fire every allowed request at once:
* ten parallel model calls still hammer Ollama (and hold ten sockets). These
* counters cap what is running *right now*. Kept on globalThis for the same
* reason as the buckets.
*/
const slotStore = globalThis as unknown as { __freezmoSlots?: Map<string, number> };
const slots: Map<string, number> = (slotStore.__freezmoSlots ??= new Map());
/** Take one of `max` in-flight slots for `key`. */
export function acquireSlot(key: string, max: number): boolean {
const current = slots.get(key) ?? 0;
if (current >= max) return false;
slots.set(key, current + 1);
return true;
}
/** Give an in-flight slot back. */
export function releaseSlot(key: string): void {
const current = slots.get(key) ?? 0;
if (current <= 1) slots.delete(key);
else slots.set(key, current - 1);
}
/** How many slots are in flight for a key (diagnostics and tests). */
export function inFlight(key: string): number {
return slots.get(key) ?? 0;
}
/** Best-effort client identity: proxy headers first, then a shared bucket. */
export function clientKey(request: Request): string {
const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) {
// Take the **last** hop, not the first: our reverse proxy appends the peer
// it saw, so the last entry is the one connection we can trust. Reading the
// first entry would let a client send its own `x-forwarded-for` and dodge
// every per-IP limit.
const hops = forwarded
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
const last = hops.at(-1);
if (last) return last;
const first = forwarded.split(",")[0]?.trim();
if (first) return first;
}
return (
request.headers.get("x-real-ip") ??

View file

@ -8,19 +8,6 @@ import { z } from "zod";
export const difficultySchema = z.enum(["easy", "medium", "hard"]);
export type Difficulty = z.infer<typeof difficultySchema>;
/**
* Photos and rendered pages travel as base64 data URLs — never plain URLs.
* A URL here would make Ollama fetch an arbitrary address on our behalf (an
* SSRF hole through the model host), so the shape is part of the contract.
*/
export function dataUrlSchema(maxChars: number) {
return z
.string()
.min(32)
.max(maxChars)
.regex(/^data:[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*;base64,/i, "must be a base64 data URL");
}
const baseActivity = {
/** Short label shown in the feed chrome, e.g. "Cell biology". */
topic: z.string().min(1).max(120).optional(),

View file

@ -23,26 +23,6 @@ const nextConfig: NextConfig = {
* the e2e suites are unaffected.
*/
output: "standalone",
/**
* Security headers. The app has no accounts, so these are about containment:
* never framed (clickjacking), never sniffed into a script, and no camera,
* microphone or location access it does not need (the camera button uses a
* file input with `capture`, which is not gated by Permissions-Policy).
*/
async headers() {
return [
{
source: "/(.*)",
headers: [
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Content-Security-Policy", value: "frame-ancestors 'none'" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), payment=()" },
],
},
];
},
allowedDevOrigins: [
"127.0.0.1",
"localhost",

View file

@ -1,6 +1,6 @@
{
"name": "freezmo",
"version": "0.1.6",
"version": "0.1.2",
"private": true,
"license": "GPL-3.0-only",
"scripts": {

View file

@ -9,7 +9,7 @@
*
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
*/
const CACHE_VERSION = "freezmo-v52";
const CACHE_VERSION = "freezmo-v50";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`;

View file

@ -1,169 +0,0 @@
/**
* Contrast diagnostic (NOT part of `npm run test:e2e`): axe cannot judge text
* on a blurred glass surface — it reports those nodes as "incomplete" — so
* this samples the **rendered pixels** instead.
*
* For every text element it takes a screenshot of that element's box, finds
* the dominant colour in it (the background: glyphs are the minority of the
* pixels) and compares it with the text colour from the computed style using
* the WCAG relative-luminance formula. Large text (>= 24px, or >= 18.66px
* bold) is held to 3:1, everything else to 4.5:1.
*
* node tests/contrast.mjs # every screen, both themes
* node tests/contrast.mjs study # filter by screen name
*
* It always exits 0 — the numbers are for tuning the palette.
*/
import { BASE, DECK, launchBrowser, makePage, seed } from "./lib.mjs";
const filter = process.argv[2] ?? "";
const browser = await launchBrowser();
const failures = [];
/** WCAG relative luminance of an [r, g, b] triple (0-255). */
const LUMINANCE = `
const luminance = ([r, g, b]) => {
const channel = (value) => {
const v = value / 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
};
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
};
const contrast = (a, b) => {
const [l1, l2] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (l1 + 0.05) / (l2 + 0.05);
};
`;
const screens = [
["library", "/", null],
["study", "/deck/seed_deck", null],
["create", "/create", null],
["homework", "/homework", null],
["chat", "/chat", null],
["stats", "/stats", null],
["exam", "/exam", null],
["settings", "/", "settings"],
];
for (const [label, path, mode] of screens) {
if (filter && !label.includes(filter)) continue;
for (const theme of ["dark", "light"]) {
const { context, page } = await makePage(browser, { width: 390, height: 844, deviceScaleFactor: 1 });
await seed(page, { decks: [DECK], theme });
await page.goto(`${BASE}${path}`, { waitUntil: "networkidle" });
await page.waitForTimeout(1000);
if (mode === "settings") {
await page.locator("nav.tabbar").getByRole("button").click();
await page.getByRole("dialog").waitFor({ timeout: 6000 });
await page.waitForTimeout(700);
}
// Every text-bearing element with a visible box, small enough to sample.
// Content behind an open sheet is dimmed on purpose and not readable, and
// disabled controls are exempt from 1.4.3 — both are skipped.
const targets = await page.evaluate(() => {
const out = [];
const dialog = document.querySelector('[role="dialog"]');
const root = dialog ?? document;
for (const element of root.querySelectorAll("p, span, li, h1, h2, h3, button, a, label, dt, dd")) {
if (element.closest("[disabled]") || element.closest('[aria-disabled="true"]')) continue;
const text = (element.textContent ?? "").trim();
if (text.length < 2) continue;
if (element.children.length > 0 && [...element.children].some((child) => (child.textContent ?? "").trim().length > 0)) continue;
const style = getComputedStyle(element);
if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity) < 0.5) continue;
const box = element.getBoundingClientRect();
if (box.width < 8 || box.height < 8 || box.bottom < 0 || box.top > window.innerHeight) continue;
out.push({
id: out.length,
text: text.slice(0, 40),
color: style.color,
fontSize: Number.parseFloat(style.fontSize),
fontWeight: Number(style.fontWeight),
box: { x: box.x, y: box.y, width: box.width, height: box.height },
});
}
return out;
});
const screenshot = await page.screenshot({ type: "png", scale: "css" });
const dataUrl = `data:image/png;base64,${screenshot.toString("base64")}`;
const report = await page.evaluate(
async ({ targets, dataUrl, luminanceSource }) => {
const image = await new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = dataUrl;
});
const canvas = document.createElement("canvas");
canvas.width = image.width;
canvas.height = image.height;
const ctx = canvas.getContext("2d");
ctx.drawImage(image, 0, 0);
const full = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
const { contrast } = new Function(`${luminanceSource} return { contrast };`)();
const parseColor = (value) => {
const match = /rgba?\(([^)]+)\)/.exec(value);
if (!match) return null;
const parts = match[1].split(",").map((part) => Number.parseFloat(part));
return [parts[0], parts[1], parts[2], parts[3] ?? 1];
};
const results = [];
for (const target of targets) {
const text = parseColor(target.color);
if (!text) continue;
// Sample a slightly larger box and ignore pixels close to the text
// colour: those are the glyphs themselves, not the background.
const x0 = Math.max(0, Math.floor(target.box.x) - 2);
const y0 = Math.max(0, Math.floor(target.box.y) - 2);
const x1 = Math.min(canvas.width, Math.ceil(target.box.x + target.box.width) + 2);
const y1 = Math.min(canvas.height, Math.ceil(target.box.y + target.box.height) + 2);
if (x1 - x0 < 4 || y1 - y0 < 4) continue;
const counts = new Map();
let sampled = 0;
for (let y = y0; y < y1; y += 1) {
for (let x = x0; x < x1; x += 1) {
const index = (y * canvas.width + x) * 4;
const pixel = [full[index], full[index + 1], full[index + 2]];
const distance = Math.max(
Math.abs(pixel[0] - text[0]),
Math.abs(pixel[1] - text[1]),
Math.abs(pixel[2] - text[2]),
);
if (distance <= 24) continue; // glyph pixel (or its antialiasing)
const key = `${pixel[0]},${pixel[1]},${pixel[2]}`;
counts.set(key, (counts.get(key) ?? 0) + 1);
sampled += 1;
}
}
if (sampled < 12) continue; // all glyphs: nothing to measure against
let best = null;
for (const [key, count] of counts) if (!best || count > best.count) best = { key, count };
const background = best.key.split(",").map(Number);
const ratio = contrast(text, background);
const large = target.fontSize >= 24 || (target.fontSize >= 18.66 && Number(target.fontWeight) >= 700);
const minimum = large ? 3 : 4.5;
if (ratio < minimum) {
results.push({ text: target.text, ratio: Number(ratio.toFixed(2)), minimum, color: target.color, background: best.key });
}
}
return results;
},
{ targets, dataUrl, luminanceSource: LUMINANCE },
);
if (report.length > 0) {
console.log(`\n[${theme}] ${label} — ${report.length} below minimum`);
for (const row of report) {
console.log(` ${row.ratio}:1 (needs ${row.minimum}) "${row.text}" — ${row.color} on rgb(${row.background})`);
failures.push({ theme, label, ...row });
}
}
await context.close();
}
}
console.log(`\ncontrast scan done — ${failures.length} below-minimum findings`);
await browser.close();

View file

@ -74,7 +74,7 @@ const dragDown = async (page, locator, dy = 170, steps = 9) => {
check("CTA >= 44px tall", ctaBox.height >= 43.5, String(Math.round(ctaBox.height)));
await page.getByRole("link", { name: "Homework", exact: true }).click();
await page.waitForTimeout(700);
const hwCta = page.getByRole("link", { name: "New homework", exact: true });
const hwCta = page.getByRole("link", { name: "Make my homework", exact: true });
check("homework quick action", await hwCta.isVisible());
await page.getByRole("link", { name: "Study Decks", exact: true }).click();
await page.waitForTimeout(700);
@ -174,7 +174,7 @@ for (const locale of ["en", "cs"]) {
check("scrolled: drag snaps back, sheet stays", (await dialog.count()) === 1);
await page.locator(".sheet-scroll").evaluate((el) => (el.scrollTop = 0));
await page.waitForTimeout(250);
await dragDown(page, dialog.locator("#settings-title"));
await dragDown(page, dialog.locator("h2"));
await page.waitForTimeout(800);
check("handle+header swipe closes the sheet", (await dialog.count()) === 0);

View file

@ -97,7 +97,7 @@ const browser = await launchBrowser();
await page.goto(target, { waitUntil: "networkidle" });
await page.waitForTimeout(600);
check("share: chooser lists the file", await page.getByText("shared.txt").isVisible());
await page.getByRole("button", { name: "New homework" }).first().click();
await page.getByRole("button", { name: "Make my homework" }).first().click();
await page.waitForTimeout(1200);
check("share: homework got the file", await page.getByText("shared.txt").first().isVisible());
await context.close();

View file

@ -447,8 +447,6 @@ const browser = await launchBrowser();
VAPID_PRIVATE_KEY: vapid.privateKey,
VAPID_SUBJECT: "mailto:test@example.com",
PUSH_DIR: path.join(REPO, "tests", ".tmp", "push"),
// The endpoint in this suite is a fake host: allow it explicitly.
PUSH_ALLOWED_HOSTS: "push.example.invalid",
});
try {

View file

@ -16,7 +16,7 @@ const browser = await launchBrowser();
check("library title", await page.getByRole("heading", { name: "Your icebox" }).isVisible());
const sidebar = page.getByRole("complementary", { name: "Sidebar" });
for (const row of ["New deck", "New homework", "All decks", "Unorganized", "Biology", "Progress", "Exam mode", "Settings"]) {
for (const row of ["New deck", "Make my homework", "All decks", "Unorganized", "Biology", "Progress", "Exam mode", "Settings"]) {
check(`sidebar row: ${row}`, (await sidebar.getByText(row, { exact: true }).count()) >= 1);
}
check("desktop hides the tab bar", !(await page.locator("nav.tabbar").isVisible()));
@ -74,7 +74,7 @@ const browser = await launchBrowser();
await page.getByRole("link", { name: "Homework", exact: true }).click();
await page.waitForTimeout(600);
check("homework tab empty state", await page.getByRole("link", { name: "New homework", exact: true }).isVisible());
check("homework tab empty state", await page.getByRole("link", { name: "Make my homework", exact: true }).isVisible());
await context.close();
}

View file

@ -22,7 +22,6 @@ const SUITES = [
"phase1-test",
"phase2-test",
"phase3-test",
"security-test",
"chat-test",
"review-test",
];

View file

@ -1,208 +0,0 @@
/**
* Security limits: the model gate (per-IP concurrency + the account-wide
* budget), the data-URL contract, the proxy-hop identity, the disk guards and
* the response headers.
*
* Runs against its own instance with a slow stub Ollama and deliberately tiny
* limits, so every guard is observable in a second or two.
*/
import http from "node:http";
import { mkdirSync, readFileSync, rmSync } from "node:fs";
import { launchBrowser, makeChecker, makePage, portInUse, seed, spawnApp, waitForHttp } from "./lib.mjs";
const { check, finish } = makeChecker();
const STUB_PORT = 11997;
const APP_PORT = 3210;
const STUB_BASE = `http://127.0.0.1:${STUB_PORT}`;
const APP = `http://127.0.0.1:${APP_PORT}`;
const REPORTS_DIR = new URL("./.tmp/security-reports", import.meta.url).pathname;
const PUSH_DIR = new URL("./.tmp/security-push", import.meta.url).pathname;
/* A deliberately slow stub: model calls stay in flight long enough to test
the concurrency gate, and the payload itself never matters here. */
const stub = http.createServer((request, response) => {
if (request.url?.startsWith("/api/tags")) {
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ models: [{ name: "stub", model: "stub" }] }));
return;
}
request.on("data", () => {});
request.on("end", () => {
setTimeout(() => {
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ message: { content: "{}" } }));
}, 700);
});
});
await new Promise((resolve) => stub.listen(STUB_PORT, "127.0.0.1", resolve));
rmSync(REPORTS_DIR, { recursive: true, force: true });
rmSync(PUSH_DIR, { recursive: true, force: true });
mkdirSync(REPORTS_DIR, { recursive: true });
mkdirSync(PUSH_DIR, { recursive: true });
check("security app port is free", !(await portInUse(APP_PORT)));
const app = spawnApp(APP_PORT, {
OLLAMA_BASE_URL: STUB_BASE,
OLLAMA_AUTH: "local",
OLLAMA_VERIFY_KEY: "false",
OLLAMA_TIMEOUT_MS: "10000",
RATE_LIMIT_CONCURRENT: "2",
RATE_LIMIT_CONCURRENT_TOTAL: "3",
RATE_LIMIT_MODEL_TOTAL: "6",
RATE_LIMIT_REPORTS: "1",
REPORTS_DIR,
REPORTS_MAX_BYTES: "900",
PUSH_DIR,
PUSH_MAX_SUBSCRIPTIONS: "2",
VAPID_PUBLIC_KEY: "BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U",
VAPID_PRIVATE_KEY: "UUxI4O8-FbRouAevSmBQ6o18hgE4nSG3qwvJTfKc-ls",
VAPID_SUBJECT: "mailto:test@example.com",
});
const browser = await launchBrowser();
try {
const up = await waitForHttp(`${APP}/api/health`);
check("security app instance is up", up);
if (up) {
/* 1. per-IP concurrency: only N model calls in flight ---------------- */
const generate = (body) =>
fetch(`${APP}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: "Photosynthesis needs light and water.", ...body }),
});
const parallel = await Promise.all([generate({}), generate({}), generate({})]);
const statuses = parallel.map((response) => response.status).sort();
check(
"concurrency: the third parallel model call is refused as busy",
statuses.filter((status) => status === 200).length === 2 && statuses.includes(429),
JSON.stringify(statuses),
);
const busy = await parallel.find((response) => response.status === 429)?.json();
check("concurrency: the refusal is a friendly busy 429", busy?.kind === "busy" && typeof busy?.error === "string", JSON.stringify(busy));
// Drain the two streams so their slots are released.
await Promise.all(parallel.map((response) => response.text().catch(() => "")));
/* 2. the account-wide budget: even sequential calls stop ------------
Two calls were admitted above (the refused one never counted), and the
data-URL call below adds one — so with a budget of six, three more get
through and the next is refused. */
const plainUrl = await fetch(`${APP}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ images: [`http://169.254.169.254/latest/meta-data/`, "x".repeat(40)] }),
});
check("data URLs: a plain URL is rejected", plainUrl.status === 400, String(plainUrl.status));
const dataUrl = await fetch(`${APP}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ images: [`data:image/jpeg;base64,${"A".repeat(200)}`] }),
});
check("data URLs: a real data URL is accepted", dataUrl.status === 200, String(dataUrl.status));
await dataUrl.text().catch(() => "");
const admitted = [];
for (let index = 0; index < 3; index += 1) {
const response = await generate({});
admitted.push(response.status);
await response.text().catch(() => "");
}
const overBudget = await generate({});
const overBudgetBody = overBudget.status === 429 ? await overBudget.json() : null;
check(
"budget: the model-call budget stops a flood",
admitted.every((status) => status === 200) && overBudget.status === 429 && overBudgetBody?.kind === "rate_limited",
`${JSON.stringify(admitted)} then ${overBudget.status} ${JSON.stringify(overBudgetBody)}`,
);
/* 4. the rate-limit identity is the last proxy hop ------------------ */
const report = (forwarded) =>
fetch(`${APP}/api/reports`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-forwarded-for": forwarded },
body: JSON.stringify({
deckId: "seed_deck",
activityId: "a1",
type: "multiple_choice",
question: "What colour is a clear daytime sky?",
at: Date.now(),
}),
});
const spoofOne = await report("9.9.9.9, 10.0.0.1");
const spoofTwo = await report("8.8.8.8, 10.0.0.1");
const other = await report("7.7.7.7, 10.0.0.2");
check(
"identity: a spoofed x-forwarded-for cannot dodge the per-IP limit",
spoofOne.status === 200 && spoofTwo.status === 429 && other.status === 200,
`${spoofOne.status}/${spoofTwo.status}/${other.status}`,
);
/* 5. the reports file stops growing at its cap ---------------------- */
for (let index = 0; index < 6; index += 1) {
await report(`7.7.7.7, 10.0.0.${10 + index}`);
}
const reportsSize = readFileSync(`${REPORTS_DIR}/reports.jsonl`, "utf8").length;
check("reports: the JSONL stops at its size cap", reportsSize > 0 && reportsSize < 2000, `${reportsSize} bytes`);
/* 6. push endpoints must be real push services ---------------------- */
const subscribe = (endpoint) =>
fetch(`${APP}/api/push`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint, keys: { p256dh: "B".repeat(40), auth: "C".repeat(20) } }),
});
const evil = await subscribe("https://evil.example/collect");
check("push: an arbitrary endpoint is rejected (no SSRF)", evil.status === 400, String(evil.status));
const good = await subscribe("https://fcm.googleapis.com/fcm/send/abc");
const goodSecond = await subscribe("https://updates.push.services.mozilla.com/wpush/v2/abc");
const overCap = await subscribe("https://web.push.apple.com/abc");
check(
"push: subscriptions are capped",
good.status === 200 && goodSecond.status === 200 && overCap.status === 429,
`${good.status}/${goodSecond.status}/${overCap.status}`,
);
/* 7. response headers ------------------------------------------------ */
const { context, page } = await makePage(browser);
await seed(page, { decks: [] });
await page.goto(`${APP}/`, { waitUntil: "domcontentloaded" });
const headers = await page.evaluate(async () => {
const response = await fetch("/", { cache: "no-store" });
return {
frame: response.headers.get("x-frame-options"),
csp: response.headers.get("content-security-policy"),
nosniff: response.headers.get("x-content-type-options"),
referrer: response.headers.get("referrer-policy"),
};
});
check(
"headers: the app refuses to be framed and sniffed",
headers.frame === "DENY" &&
(headers.csp ?? "").includes("frame-ancestors 'none'") &&
headers.nosniff === "nosniff" &&
headers.referrer === "strict-origin-when-cross-origin",
JSON.stringify(headers),
);
/* 8. cross-origin writes stay rejected ------------------------------- */
const crossOrigin = await fetch(`${APP}/api/reports`, {
method: "POST",
headers: { "Content-Type": "application/json", Origin: "https://evil.example" },
body: JSON.stringify({ deckId: "d", activityId: "a", type: "multiple_choice", question: "q", at: Date.now() }),
});
check("cross-origin POST is refused", crossOrigin.status === 403, String(crossOrigin.status));
await context.close();
}
} finally {
app.stop();
stub.close();
await browser.close();
}
finish();

View file

@ -89,7 +89,7 @@ for (const viewport of [{ width: 390, height: 844 }, { width: 360, height: 740 }
await page.getByRole("link", { name: "Homework", exact: true }).click();
await page.waitForTimeout(600);
await page.getByRole("link", { name: "New homework", exact: true }).click();
await page.getByRole("link", { name: "Make my homework", exact: true }).click();
await sampleBar(page, "homework empty state → homework");
check("homework headline is the step name", await page.getByRole("heading", { name: "The assignment" }).isVisible());
check("homework Next disabled with nothing", await page.getByRole("button", { name: "Next" }).isDisabled());