Phase 1: backup, encrypted sync and monthly-usage handling
Safety net and errors, mobile-first, desktop unchanged in behaviour. Backup and persistence - Settings -> Data & backup: 'Export all data' writes one zod-validated JSON (decks, folders, homework, progress, safe settings; the API key is not part of the shape and never appears in the file). - 'Import backup' validates, shows a preview with added/replaced counts and offers merge or replace; invalid files get a friendly error. Storage persistence (navigator.storage.persist) is shown and requestable, with the result reported. - A dismissible nudge appears after 5 new decks or 14 days without a backup and stays snoozed for 14 days. Opt-in encrypted sync (off by default) - New lib/sync.ts: PBKDF2-SHA256 (310k) -> AES-GCM-256, derived key kept as a non-extractable CryptoKey in IndexedDB (lib/sync-key.ts); the passphrase and key never leave the browser. - New POST/GET/DELETE /api/sync following the route pattern (locale, cross-origin guard, body cap, rate limit RATE_LIMIT_SYNC, auth before body parsing, zod validation). One atomic JSON file per random 128-bit sync ID under SYNC_DIR; server-stamped updatedAt is the conflict clock; SYNC_MAX_BYTES and SYNC_TOTAL_BYTES enforced (413 / 507). The server only ever sees an opaque envelope. - Settings -> Sync: enable with a passphrase, copy the sync ID, sync now, last-synced time, explicit keep-this-device / use-synced-copy conflict choice, join from another device (ID + passphrase, wrong passphrase rejected), unlock after the key is gone, delete the synced copy (works even on a locked device). Every data store marks sync dirty on write; pulled payloads are applied without marking dirty again. Monthly usage vs short-term throttling - lib/ollama.ts classifies the real 429 body (monthly/quota/upgrade vs burst) into new 'usage_limit' and 'throttled' kinds, mapped in every route and in the health probe. - The client renders the same localized message in the deck flow, homework, grading and the Settings banner: the required upgrade/new-key copy with 'Open Settings' (jumps to and focuses the key field) and 'Open Ollama'. Input and wizard drafts are kept so Retry works right after swapping the key. No model fallback. Verified: new phase-1 suite 38/38 (backup content + no key, import preview/merge/replace, junk file, persistence row, nudge + snooze, sync enable/upload/opaque blob/push/join/wrong passphrase/delete, server validation 400/401, usage-limit copy and key focus, and the real route mapping against a stub Ollama for both 429 variants) plus regression 74/74, mobile 52/52, wizard 80/80, declutter 116/116, animation 8/8, native 35/35, i18n 25/25. Key parity 638/638. Lint + build clean. SW freezmo-v25.
This commit is contained in:
parent
1dc2992fb5
commit
bb1f4e2018
31 changed files with 1933 additions and 36 deletions
39
README.md
39
README.md
|
|
@ -62,6 +62,16 @@ no database, no data leaving your machine except the requests you send to Ollama
|
|||
study sessions and homework guides; wizard drafts survive a reload or an OS kill; re-tapping
|
||||
the active tab scrolls to the top; an offline pill appears and generation is disabled with
|
||||
a reason until the connection is back.
|
||||
- **Backup, sync and limits**: *Settings → Data & backup* exports **everything** (decks, folders,
|
||||
homework, progress, safe settings — never the API key) to one JSON file and imports a backup with
|
||||
a preview and a merge/replace choice; a gentle, dismissible nudge appears after 5 new decks or 14
|
||||
days without a backup, and the storage-persistence state (`navigator.storage.persist()`) is shown.
|
||||
*Settings → Sync* is **off by default** and opt-in: the data is encrypted in the browser
|
||||
(PBKDF2-SHA256 → AES-GCM, key kept as a non-extractable `CryptoKey`) before it is uploaded, the
|
||||
server stores only an opaque blob under a random 128-bit sync ID, and conflicts are surfaced with
|
||||
an explicit *keep this device / use synced copy* choice. When Ollama's **free monthly usage** runs
|
||||
out, every flow that calls the model shows the same clear message with **Open Settings** (jumps to
|
||||
the key field) and **Open Ollama** — never a silent fallback to another model.
|
||||
- **Photos and documents**: JPG/PNG/WEBP/HEIC images plus **PDF, DOCX, PPTX, ODT/ODP/ODS, TXT, MD, CSV**
|
||||
- Documents are read **in the browser**: PDF text layers are extracted, scanned PDF pages are rendered
|
||||
as images for the vision model, DOCX/PPTX text, speaker notes and embedded images are pulled out.
|
||||
|
|
@ -403,6 +413,7 @@ client IP with a sliding window. Everything is configurable in `.env.local`:
|
|||
| `POST /api/homework` | 12 per 5 minutes | `RATE_LIMIT_HOMEWORK` |
|
||||
| `POST /api/grade` | 40 per 5 minutes | `RATE_LIMIT_GRADE` |
|
||||
| `GET /api/health` | 90 per 5 minutes | `RATE_LIMIT_HEALTH` |
|
||||
| `POST/GET/DELETE /api/sync` | 60 per 5 minutes | `RATE_LIMIT_SYNC` |
|
||||
| window for all of them | 5 minutes | `RATE_LIMIT_WINDOW_MS` |
|
||||
|
||||
When a limit is hit the route answers **429** with a `Retry-After` header and a friendly message
|
||||
|
|
@ -699,6 +710,9 @@ scripts/
|
|||
| `POST /api/homework` | `{ mode: "make" \| "guide", images?: string[], text?, language? }` | NDJSON stream: `{type:"stage"}`, `{type:"homework",homework,meta}`, `{type:"error"}` |
|
||||
| `POST /api/grade` | `{ question, referenceAnswer, keyPoints, userAnswer, language? }` | `{ correct, score, feedback, missing }` |
|
||||
| `GET /api/health` | – | `{ ok, reachable, mode, baseUrl, hasApiKey, vision, text, latencyMs, modelCount, error }` |
|
||||
| `POST /api/sync` | `{ id, envelope }` (opaque AES-GCM blob) | `{ updatedAt }` — 413 over `SYNC_MAX_BYTES`, 507 over `SYNC_TOTAL_BYTES` |
|
||||
| `GET /api/sync?id=` | – | `{ updatedAt, envelope }` or 404 |
|
||||
| `DELETE /api/sync?id=` | – | `{ ok: true }` (revokes the synced copy) |
|
||||
|
||||
All four routes accept an optional **`x-ollama-key`** header (the browser's own key; required in
|
||||
`user-key` auth mode). Missing keys answer **401** with `kind: "needs_key"`, a rejected key answers
|
||||
|
|
@ -745,8 +759,13 @@ grading, regeneration and “add more” (and the assignment language for homewo
|
|||
`freezmo.settings.v1`) and sent with each API call so the server can forward it to Ollama. It is
|
||||
never written to the server, a log, a cookie or the environment — see
|
||||
[Ollama setup](#ollama-setup) for the full story.
|
||||
- Clearing browser data deletes the library and the saved key. Use **Export JSON** for anything you
|
||||
want to keep.
|
||||
- Clearing browser data deletes the library and the saved key. Use **Export all data**
|
||||
(Settings → Data & backup) for anything you want to keep; the file never contains the API key.
|
||||
- **Opt-in sync** (`freezmo.sync.v1` + a non-extractable `CryptoKey` in IndexedDB) uploads an
|
||||
AES-GCM envelope to `POST /api/sync`. The server writes one small JSON file per sync ID under
|
||||
`SYNC_DIR` (default `.freezmo/sync`), can't decrypt it, and never receives the passphrase or the
|
||||
key. Deleting the synced copy is one button in Settings (and works even on a device that can't
|
||||
unlock the data).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -758,6 +777,7 @@ grading, regeneration and “add more” (and the assignment language for homewo
|
|||
| A document card is stuck on “Reading page …” | The pdf.js worker may be missing: run `npm run dev`/`npm run build` (they copy it to `public/pdf.worker.min.mjs`) or `node scripts/copy-pdf-worker.mjs` manually. |
|
||||
| PDF uploaded but no activities about its content | The PDF is probably image-only (a scan). Freezmo renders such pages and sends them to the vision model — check the card note (“N scanned pages were turned into images”); if it says “No readable text”, the scan may be too low quality. |
|
||||
| Deck came back in English even though the notes are not | Make sure both model calls succeed (a failed vision step falls back to raw text). The prompts force the notes' language; if you still see translation, switch `TEXT_MODEL` to a stronger instruction-follower such as `gpt-oss:120b-cloud`. |
|
||||
| “You've run out of your free monthly Ollama usage” | The account's free monthly usage is used up — this is not a short-term rate limit. Upgrade the account at `ollama.com/settings`, or create a new account and paste its key in Settings (the button jumps straight to the field). “Ollama is busy” is the short-term variant: just press Retry. |
|
||||
| “Add your Ollama API key first” | Bring-your-own-key mode is active. Paste a key from <https://ollama.com/settings/keys> on the key gate (or in the ⚙️ dialog) — it is stored only in your browser. |
|
||||
| “Ollama rejected this API key” | The key is invalid or revoked. Create a new one and paste it again; the check uses a real 1-token request, so a green tick means it truly works. |
|
||||
| “this model is not included in your free usage” | The key works, but the model is outside your plan. Use one of the ✅ models in the table above, or add credits at <https://ollama.com/settings>. |
|
||||
|
|
@ -1001,6 +1021,21 @@ Things I chose while building, with the reasoning:
|
|||
manifest side). Pull-to-refresh and the app badge were skipped on purpose — the Settings
|
||||
refresh icon already re-checks the connection, and there is no meaningful badge number.
|
||||
|
||||
40. **Backups are plain, sync is encrypted, and the key never leaves either.** The backup file is
|
||||
the same zod-validated shape the sync payload uses (`lib/backup.ts`), which is why the import
|
||||
preview can count added/replaced items without touching the key — the key is simply not part of
|
||||
the shape. Sync adds PBKDF2-SHA256 (310k iterations) → AES-GCM on top, keeps the derived key as
|
||||
a **non-extractable** `CryptoKey` in IndexedDB, and lets the server be a dumb blob store: it
|
||||
stamps `updatedAt` (the conflict clock), enforces `SYNC_MAX_BYTES` / `SYNC_TOTAL_BYTES` and
|
||||
writes atomically (temp file + rename). A device that lost its stored key can still *unlock*
|
||||
(passphrase re-derivation) or *revoke* (DELETE) the synced copy. Monthly-usage exhaustion and
|
||||
short-term throttling are told apart by inspecting the real 429 body
|
||||
(`classifyOllamaFailure`) and mapped to `usage_limit` / `throttled` kinds — the client renders
|
||||
the same localized message in every model flow, with a jump to the key field and a link to
|
||||
Ollama, and keeps the user's input so Retry works right after swapping the key. Verified by the
|
||||
phase-1 suite (38 checks, including the real route mapping against a stub Ollama returning a
|
||||
monthly-limit body, and a two-device sync round-trip).
|
||||
|
||||
14. **`gpt-oss:120b` does all content work — but it cannot read images** (`this model does not
|
||||
support image input`), so `VISION_MODEL` has to stay a multimodal model (`gemma4:31b-cloud`).
|
||||
Text-only inputs — pasted text, PDFs with a text layer, DOCX/PPTX/ODT — never touch the vision
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue