VibeAudit

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

Download .md
56/100
NOT READY
90+ ship it
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.

Next.js 16 (App Router)React 19TypeScriptConvex (database, file storage, vector search, scheduler)convex-helpers customFunctionsClerk (auth)Together.ai (Whisper large-v3 transcription, Qwen2.5-7B via OpenAI SDK, e5 embeddings)@instructor-ai/instructor + ZodTailwind CSSreact-hot-toastPlausible analytics

55 files reviewed · claude-fable-5-1 · deep audit

Findings (15)

What's wrong

`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.

Impact

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.

Fix

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`).

Paste into Cursor / Claude Code
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.
What's wrong

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.

Impact

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.

Fix

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'); } ```

Paste into Cursor / Claude Code
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.
What's wrong

`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.

Impact

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.

Fix

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.

Paste into Cursor / Claude Code
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'.
What's wrong

`@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.

Impact

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.

Fix

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.

Paste into Cursor / Claude Code
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

  1. 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).
  2. Add try/catch and a terminal failure state to whisper.chat and make saveTranscript/saveSummary/saveEmbedding tolerate a deleted note (F3, F7).
  3. 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).
  4. Put limits on the paid path: file size/type check in createNote, per-user rate limits on createNote and similarNotes, max recording duration (F2).
  5. Stop returning embeddings/transcripts from getNotes and paginate; move embeddings to their own table (F6).
  6. Make deletes safe: cascade action items, confirm before delete, move the button out of the Link (F7, F8).
  7. 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).
VibeAudit badge
Add the badge to your README
[![VibeAudit](https://vibeaudit.sh/api/badge/fx02notesgpt)](https://vibeaudit.sh/a/fx02notesgpt)

Fixed things? Re-audit.

Run a new scan on the updated repo. Use a 5-pack key or pay per audit.

Public repo URL, no signup. Private repo? Sign in with GitHub and pick it — read-only, nothing stored except the report.