VibeAudit · Claude Fable 5.1 reads a whole repo and grades it for launch. Free quick scan, $19 deep audit. Scan your own repo →
DEEP AUDIT · 2026-09-02
Nutlope/notesGPT
70–89 fix first
<70 not ready
notesGPT is a Next.js + Convex + Clerk app that records voice notes, transcribes them with Together's Whisper, extracts title/summary/action items with an LLM, and embeds transcripts for vector search. Authorization is actually solid (every Convex function goes through queryWithUser/mutationWithUser and checks ownership), but the app is not launch-ready: the recording page destructures `params` synchronously, which is broken on the pinned Next.js 16; any Whisper/LLM failure leaves a note in a permanent loading skeleton; and there is no cap on how much paid transcription/LLM/embedding work a single account can trigger. Fix the Next 16 params bug and add try/catch + failure states to the processing pipeline first, then put per-user limits on the paid path.
55 files reviewed · claude-fable-5-1 · deep audit
Findings (15)
`const Page = async ({ params: { id } }: { params: { id: Id<'notes'> } })` treats `params` as a plain object. package.json pins `next` to 16.2.6, where `params` is a Promise and synchronous access was removed (it was only a deprecated compat shim in 15). At runtime `id` is `undefined`, so `preloadQuery(api.notes.getNote, { id: undefined })` returns `{ note: null }` and every recording renders 'Note not found'. The `next build` type-check of page props will also fail against the generated `PageProps` type.
The core page users land on after every recording (router.push(`/recording/${noteId}`)) shows 'Note not found' for every note, or the production build fails outright. Also note eslint-config-next is still 14.0.1 and next.config.js uses the deprecated `images.domains`, both signs the Next 16 bump was never actually tested.
Await params: ```ts const Page = async ({ params }: { params: Promise<{ id: Id<'notes'> }> }) => { const { id } = await params; const token = await getAuthToken(); const preloadedNote = await preloadQuery(api.notes.getNote, { id }, { token }); return <RecordingPage preloadedNote={preloadedNote} />; }; ``` Run `next build` locally and fix anything else Next 16 flags (eslint-config-next version, `images.domains` -> `images.remotePatterns`).
In app/recording/[id]/page.tsx, the page component destructures `params` synchronously (`{ params: { id } }`). This project uses Next.js 16 where `params` is a Promise and sync access has been removed, so `id` is undefined and every recording shows 'Note not found'. Change the signature to `({ params }: { params: Promise<{ id: Id<'notes'> }> })` and add `const { id } = await params;` as the first line of the handler before calling preloadQuery. Also update next.config.js to use `images.remotePatterns: [{ protocol: 'https', hostname: 'img.clerk.com' }]` instead of the deprecated `images.domains`, and bump `eslint-config-next` in package.json to match the Next 16 version. Then run `next build` and confirm it passes.Any signed-in user (Clerk sign-up is open) can call `generateUploadUrl` and `createNote` in a loop. Each `createNote` schedules `internal.whisper.chat` (paid Whisper transcription of an arbitrarily large file), which then schedules `together.chat` (LLM) and `together.embed` (embeddings). There is no check on file size, file type, recording duration, notes-per-user, or requests-per-minute. `together.ts` line 139 `similarNotes` also makes a paid embedding call on every search keystroke submit with no throttle. Nothing verifies the uploaded blob is audio at all.
A single scripted account can generate thousands of transcription/LLM/embedding calls and run up your Together.ai bill, fill Convex storage, and exhaust Convex function quotas. This is the primary way this app gets financially abused after launch.
Add a per-user rate limiter (e.g. `convex-helpers/server/rateLimit` or a `usage` table keyed by userId) enforced in `createNote` and `similarNotes`. In `createNote`, look up the uploaded file via `ctx.db.system.get(storageId)` and reject if `size` exceeds a cap (e.g. 25 MB) or `contentType` is not audio/*. On the client, stop the MediaRecorder automatically at a max duration (e.g. 10 minutes). ```ts const meta = await ctx.db.system.get(storageId); if (!meta || meta.size > 25 * 1024 * 1024 || !meta.contentType?.startsWith('audio/')) { await ctx.storage.delete(storageId); throw new ConvexError('Invalid or too large audio file'); } ```
In convex/notes.ts, add abuse limits to the paid pipeline. (1) In `createNote`, before inserting the note, fetch the storage metadata with `await ctx.db.system.get(storageId)`; if it is missing, larger than 25 MB, or its contentType does not start with 'audio/', delete the file and throw a ConvexError. (2) Add a per-user rate limit using `convex-helpers/server/rateLimit` (or a simple `usage` table with a daily counter keyed by ctx.userId) and enforce it in `createNote` (e.g. 30 notes/day) and in `similarNotes` in convex/together.ts (e.g. 60 searches/hour). (3) In app/record/page.tsx, add a max recording duration (e.g. 10 minutes) that automatically calls stopRecording. Reason: every note triggers paid Whisper, LLM and embedding calls on Together.ai and there is currently no limit on how many a single account can trigger or how large the audio can be.
`getTogetherClient().audio.transcriptions.create(...)` is not wrapped in try/catch. If Together returns an error (rate limit, unsupported/too-large file, outage, timeout), the action throws and nothing updates the note: `generatingTranscript`, `generatingTitle`, `generatingActionItems` stay `true` forever, and the audio file is never deleted (deletion only happens in `saveTranscript`). Separately, line 32 `(res.text as string) || 'error'` turns an empty transcription into the literal string 'error', which is then summarized and embedded as if it were content.
Users see an infinite skeleton on the recording page and a 'generating' note in their dashboard with no way to retry or delete the underlying file. Empty recordings produce a note titled around the word 'error'. There is no user-facing failure state anywhere in the pipeline.
Wrap the call in try/catch and write a terminal failure state: ```ts try { const res = await getTogetherClient().audio.transcriptions.create({...}); if (!res.text) throw new Error('Empty transcription'); await ctx.runMutation(internal.whisper.saveTranscript, { id: args.id, transcript: res.text }); } catch (e) { console.error('Transcription failed', e); await ctx.runMutation(internal.whisper.markFailed, { id: args.id }); } ``` Add a `markFailed` internalMutation that sets the three `generating*` flags to false, sets `transcription: 'Transcription failed. Please try recording again.'`, and deletes `audioFileId` from storage. Show that state in RecordingDesktop/RecordingMobile.
In convex/whisper.ts, wrap the body of the `chat` internalAction in try/catch. On success, if `res.text` is empty throw an Error instead of substituting the string 'error'. On any failure, call a new `internal.whisper.markFailed` internalMutation (add it to the same file) that patches the note with `generatingTranscript: false, generatingTitle: false, generatingActionItems: false, title: 'Transcription failed', transcription: 'Transcription failed. Please try recording again.', summary: ''` and deletes the audio file via `ctx.storage.delete(note.audioFileId)`. Reason: today a Together.ai error leaves the note in a permanent loading skeleton with the audio file orphaned in storage, and empty transcriptions get summarized as the literal word 'error'.
`@instructor-ai/instructor@0.0.5` (line 14) is a 2024 release built on `zod-to-json-schema` and zod v3 internals (`_def`). `zod` is pinned to `^4.4.3`, whose internal representation changed (`_zod`) and is not understood by `zod-to-json-schema`. `client.chat.completions.create({ response_model: { schema: NoteSchema } })` in convex/together.ts line 53 will either throw or produce an empty JSON schema, and the catch block on line 76 then writes `summary: 'Summary failed to generate'`, `title: 'Title'`, `actionItems: []` for every note.
If the incompatibility holds, every recording gets the title 'Title', no action items, and a placeholder summary — the entire value proposition of the app silently disappears, and the failure is only visible in Convex logs.
Either pin zod to `^3.23` (what instructor 0.0.5 expects) or drop Instructor and call the Together/OpenAI client directly with `response_format: { type: 'json_object' }` and validate with `NoteSchema.safeParse(JSON.parse(content))`. Record a real transcript end-to-end and confirm the title is not 'Title' before launch.
The project uses @instructor-ai/instructor ^0.0.5 together with zod ^4, which are incompatible (instructor 0.0.5 relies on zod v3 internals via zod-to-json-schema). In convex/together.ts, remove the Instructor dependency: call `togetherai.chat.completions.create` directly with `response_format: { type: 'json_object' }`, include the JSON shape in the system prompt, then `JSON.parse` the response content and validate it with `NoteSchema.safeParse`; on parse/validation failure retry up to 2 times before falling into the existing catch path. Remove @instructor-ai/instructor from package.json. Reason: with the current versions structured extraction is likely throwing on every note, so every note gets the fallback title 'Title' and no action items.Quick wins
- · Bump eslint-config-next to match Next 16 and replace `images.domains` with `images.remotePatterns` in next.config.js so `next build` runs clean.
- · Remove the debug `console.log({ searchQuery })` in app/dashboard/dashboard.tsx and `console.log({ results })` in convex/together.ts (the latter logs users' note ids and scores to Convex logs).
- · Fix the typo `.replace('/n', ' ')` → `.replace(/\n/g, ' ')` in convex/together.ts lines 145 and 173 (currently a no-op).
- · Add a `key` based on `item._id` instead of array index in the action-item and note lists to avoid stale-row rendering after deletes.
- · Wrap the `performMyAction` search call in try/catch with a toast so a Together outage doesn't produce an unhandled rejection.
- · Fill in or delete pnpm-workspace.yaml — the `allowBuilds` values are literal placeholder strings ('set this to true or false').
What's already good
- · Authorization is done right: every client-callable function goes through queryWithUser/mutationWithUser/actionWithUser, and getNote, removeNote, removeActionItem all check `userId` ownership; vector search filters by userId.
- · Pipeline steps (whisper.chat, together.chat, embed, saveSummary, saveEmbedding, saveTranscript) are internal functions, so clients cannot invoke paid LLM/transcription work directly with arbitrary input.
- · Convex schema uses strict validators with indexes on every access path (by_userId, by_noteId) and a filtered vector index.
- · Audio is deleted from storage immediately after successful transcription, limiting retention of raw voice data.
- · Server-side preloading with the Clerk 'convex' JWT template plus clerkMiddleware protecting everything except '/' gives correct SSR auth without flashes.
- · LLM extraction output is schema-validated (zod) rather than free text, which limits prompt-injection blast radius to the user's own note.
Do this first
- Fix the Next 16 `params` bug in app/recording/[id]/page.tsx, run `next build`, and click through record → recording page end to end (F1).
- Add try/catch and a terminal failure state to whisper.chat and make saveTranscript/saveSummary/saveEmbedding tolerate a deleted note (F3, F7).
- Verify structured extraction actually works with zod 4 — record a real note and confirm the title isn't 'Title'; drop Instructor or pin zod 3 if it fails (F4).
- Put limits on the paid path: file size/type check in createNote, per-user rate limits on createNote and similarNotes, max recording duration (F2).
- Stop returning embeddings/transcripts from getNotes and paginate; move embeddings to their own table (F6).
- Make deletes safe: cascade action items, confirm before delete, move the button out of the Link (F7, F8).
- Harden the recording UI against mic denial and upload failure (F9), then clean up the low items (duplicate Toaster, fire-and-forget mutations, Header null handling, cron for deleteOldFiles).
Fixed things? Re-audit.
Run a new scan on the updated repo. Use a 5-pack key or pay per audit.