Skip to content

Production audit

Five independent read-only reviews on 2026-09-10 (security, correctness, frontend, engineering practice, editor experience and live search quality). Every item was verified against the code or the live deployment. Effort: S under an hour, M a day, L several days. File paths are relative to backend/ unless prefixed frontend/.

Area Critical High Medium Low
Backend security 0 2 8 10
Backend correctness and data 2 5 9 4
Frontend 2 8 14 6
Engineering and operations 1 7 9 7
Admin UX and search quality 1 4 9 5

Fixed during the audit: the users-table SQL dump moved out of the repo, .gitignore corrected, worker state files stripped from the deploy bundle and the backend redeployed.

# Item Status
1–3, 7, 8 Search sync: purge all indexes on unpublish, reads inside the transaction, shared projection, per-language ids, vectors after the read Fixed in src/search/sync.ts, src/search/project.ts; gate audit section round-trips unpublish/republish
4, 6 Sort ranking rule, maxTotalHits in code Fixed and applied to both instances (scripts/configure-search.ts)
5 Search input allowlists Fixed; crafted filters return 200 with the filter ignored
9 Search hits link to the material at the cue time Fixed; the material page seeks the audio to ?t=
10 Sheikh cards 404 Fixed; facets return the slug
11 XSS via CMS names; javascript: links Fixed
12, 13 Fetch timeout; 500 page; header fails soft Fixed
14 Site URL and sitemap API base Fixed; SITE_URL is set by the deploy script
15, 16 Locale-only search; article index 404 Fixed
17 Rate limit on the API host Partly: in-app per-IP bucket on /search with the site server exempt. The free Cloudflare plan allows one rate-limit rule per zone and no ip.src exemption, so the edge rule stays on the site host only.
18 Stale jobs re-queued forever Fixed; fail after three claims
19, 20 Transcript FK cascade; hasTranscript backfill Fixed in migration 20260910_070000; applied locally. Pending on production: needs a deliberate run of pnpm payload migrate against the VPS database.
22 ESLint crash Fixed; lint runs (0 errors, 3 warnings)
23 sharp advisories Fixed; 0.35.4
24 fillDuration overwrote manual durations Fixed
25 Media accepts SVG Fixed; raster images only
21 Git Done: backend f315bf1, frontend 52180dd (its first commit). Both apps redeployed with the fixes: releases 20260910-064919 (api) and 20260910-065023 (site).
  1. Unpublished material stays in semantic search. src/search/sync.ts:216 clears only the materials and segments indexes on unpublish or delete; sentences and passages keep the text, and enrich() returns the material with overrideAccess. Republishing never re-adds segments because status is not in the denormalized list. Fix: purge all four indexes on unpublish/delete; reindex on publish. S.
  2. Admin-saved materials are indexed stale or not at all. src/search/sync.ts:37 reads the material without req, outside the saving transaction: a new material is not found and never indexed; an edited title pushes the old title. Fix: pass req, as reindexMaterialSegments already does. S.
  3. Edited materials lose language, year, titleFolded in the index. sync.ts:53 projects fewer fields than the bulk indexer (src/migrate/indexSearch.ts:129) and addDocuments replaces the document, so an edited material drops out of title search and the year facet. Fix: one shared projection function. S.
  4. Sorting by newest/oldest returns HTTP 500. src/search/meili.ts:135 sets ranking rules for segments without sort, so Meilisearch rejects the sort parameter. Fix: add 'sort'. S.
  5. Search filters are built from unvalidated request fields. src/endpoints/search.ts:248 interpolates type, sheikhId, seriesId, since, language into Meilisearch filter strings; a crafted array rewrites the filter, and transcript scope has no published clause. Fix: allowlist enums, Number.isInteger on ids. S.
  6. maxTotalHits is not in code. The live index was raised by hand; a rebuild from configureIndexes silently caps counts at 1,000. Fix: set it in src/search/meili.ts. S.
  7. Saving an English transcript deletes the Arabic one from search. sync.ts:92 looks up one transcript per material regardless of language and segment ids are material_index. Fix: key by language, index every transcript. M.
  8. The semantic reindex runs unawaited before commit. sync.ts:147 fires in the background while the transaction is open and usually finds nothing. Fix: run after commit, or pass req and await. M.
  1. Search hits play nothing. frontend/src/pages/search.astro:304 renders hits as play buttons, but no player exists and the handler only highlights. Fix: link each hit to the material with ?t=start and honour it in the detail page. S.
  2. Every homepage sheikh card is a 404. frontend/src/pages/index.astro:300 links by id where the route resolves by slug. Fix: return the slug from facets. S.
  3. Stored XSS from CMS content. frontend/src/pages/index.astro:201 renders sheikh and series names with set:html. Fix: plain interpolation. S. Related: frontend/src/lib/lexical.ts:57 allows javascript: links; allow only http(s), mailto and relative. S.
  4. No fetch timeout anywhere. frontend/src/lib/api.ts:64: a stalled API hangs every server render. Fix: AbortSignal.timeout(8000). S.
  5. No 500 page and no error boundary. Any API error, even on the 404 page, becomes bare “Internal Server Error” because the header awaits section counts. Fix: 500.astro; cached fallback in the header. S.
  6. Sitemap points at localhost and the wrong domain. frontend/src/pages/sitemap.xml.ts:28 reads an env var that is never set; :18 hardcodes https://kalelm.com; no site in astro.config.mjs, so canonicals trust the Host header, which is cache-poisonable under the new edge rule. Fix: site from env, PUBLIC_API_URL. S.
  7. English pages search only English material. frontend/src/pages/search.astro:47 passes the locale as the language filter, so /en/search sees 9 recordings and the English homepage chips return nothing. Fix: filter by language only when asked. S.
  8. Article index 404s while every header and footer links to it. frontend/src/pages/article/index.astro:16. Fix: empty state. S.
  1. No rate limit on the API host. Semantic search costs 1.5 s of the embedder per call, login lockout is 5 tries, search logs are written per anonymous query. Fix: a Cloudflare rate-limit rule on kalelm-api like the frontend’s, plus a small per-IP bucket on /search. M.
  2. Stale jobs are re-queued forever. src/endpoints/pipeline.ts:259 ignores attempts on reclaim; a job that kills its worker burns GPU every 30 minutes. Fix: fail after three claims. S.
  3. Deleting a material with a transcript fails. transcripts.material_id is NOT NULL with ON DELETE SET NULL. Fix: migration to cascade, plus an index purge. S.
  4. hasTranscript is never backfilled by the migration despite the field’s comment; bulk imports skip the hook. Fix: one UPDATE … SET has_transcript = EXISTS(…) at the end of the transcripts stage. S.
  5. Git. Backend has one commit and 230 untracked files; the frontend has no repository. Fix: commit now. S.
  6. ESLint has never run. eslint.config.mjs:13 wraps an already-flat config in FlatCompat and crashes. The verified fix: import eslint-config-next/core-web-vitals and /typescript directly, drop @eslint/eslintrc. S.
  7. pnpm audit: sharp 0.34.2 has two high advisories. Bump to 0.35.4 or later. S.
  8. fillDuration overwrites manual durations. src/hooks/fillDuration.ts:24 checks the partial data only, so any partial update, including the transcript hook’s flag refresh, refetches and overwrites. Fix: consult originalDoc. S.
  9. Media accepts SVG. src/collections/Media.ts:50 has no mime allowlist; an editor’s SVG with a script runs on the admin origin. Fix: allow jpeg, png, webp. S.
  • src/utilities/ttlCache.ts keys on raw query strings with no size cap; random parameters can grow it without bound. Key on validated params, cap entries. S.
  • src/endpoints/facets.ts:56 caches a returned {error} for five minutes. Throw instead. S.
  • pipeline.ts:344: complete and heartbeat never check the job’s worker matches the caller; one leaked CRON_SECRET can write any running job’s transcript. Add and worker = $n. S.
  • pipeline.ts:381: transcript create and job update are not one transaction. Wrap them. M.
  • pipeline.ts:322: cue text length and stats size are unbounded. Cap. S.
  • Postgres over the internet with sslmode=no-verify. Pass the server certificate as ca and use verify-full. S.
  • Public search runs with the Meilisearch master key. Create a search-only key. S.
  • Bearer comparisons use ===; use timingSafeEqual. S.
  • materials.audioUrl is free text fetched server-side and by workers. Validate https://files.kalelm.com/. S.
  • Two auto-transcription paths race: the Gemini hook and the pipeline queue both target the same materials; Gemini also hardcodes Arabic. Delete the Gemini path or gate it. S.
  • The durations panel button spawns the WordPress migration entrypoint, which needs nvm, tsx, mysql2 and the WP database; dead on production. Move it to a Payload job. M.
  • Transcripts.cues has no validation on admin edit; reuse the pipeline’s validCues. S.
  • transcripts.language is nullable; make it required. S.
  • groupByMaterial discards sort=newest|oldest; longest is a no-op. S.
  • Tree paging has no tiebreak; pages overlap on equal dates. S.
  • Facet counts are cues, not materials, so counts read 3 to 40 times too high; single-word queries surface one-word cues with no context; paging stops at about 200 materials while claiming thousands. Facet with distinct, run addContext for keyword, page by material. M.
  • Sheikhs marked hidden are readable through the API and listed by facets and tree. S.
  • search-logs written per anonymous query; sample or throttle. S.
  • lib/pipelineWorker.ts:91 passes the entire server environment to the spawned worker. Allowlist. S.
  • Stale drizzle snapshot; orphan transcription, transcription_models tables. Regenerate, drop. M.
  • Locale dropped on pagination and several link builders; page 2 of an English list lands in Arabic. Route through localePath. S.
  • degraded search responses never shown to the reader. Add the field and a notice. S.
  • No Open Graph, Twitter or JSON-LD tags; shared links have no preview. M.
  • hreflang advertises article pages that 404 in en and ur. S.
  • Homepage labels claim “added today”, “most played this week” over all-time data. Reword or filter. S.
  • Homepage series preview shows unrelated lessons. S.
  • sheikhs(500) and four facet queries on nearly every request; the section cache is single-locale. Memoize per locale. S.
  • Filter panel renders 708 sheikhs and every series hidden in HTML: listing pages are 266 KB. Cap and fetch on demand. M.
  • Sitemap lists /search/ and stub pages and nears the 50k URL limit in one file. Split by collection. M.
  • Download button on the cross-origin mp3 opens inline. Proxy or serve with attachment headers. M.
  • Transcript sync only for the <audio> element, never for YouTube, which is 12,866 of the transcripts; no ?t= deep link. M.
  • No <main> landmark on most pages, no skip link, heading levels jump. S.
  • Dead href="#" in header and footer: series, quizzes, live, about, feedback, socials. S.
  • No CSP or frame headers from the app. M.
  • scripts/verify.ts connects to Postgres at the top, so even the service-free sections cannot run in CI. Move the client into the sections that need it, then run tsc, build, verify exports i18n and worker/test_worker.py in CI. M.
  • importMap.js is stale and not regenerated on build. Add payload generate:importmap to the build script. S.
  • No startup env validation; a deploy without MEILI_HOST passes health and 500s on search. S.
  • deploy-hangar.sh: token visible in ps, missing healthy treated as success, hand-maintained prune list; build from git archive instead. S.
  • Backups: no failure alert, no restore rehearsal. Add a ping on success and a monthly pg_restore --list. S.
  • No uptime probe beyond Hangar’s health; no error tracking; log location and retention undocumented. S.
  • README.md is the Payload template. Replace with ten lines. S.
  • Frontend has no lint, no astro check, no tests; install @astrojs/check. S.
  • Node pins differ between .nvmrc (22.17) and the host (22.23). S.
  • Rich text editor lacks headings, lists, blockquote and images; editors cannot write a structured article. S.
  • Pipeline transcripts go straight into public search with no review state. Add a status and a filter preset. M.
  • No link from a material to its transcript or job; re-queue is admin-only. Add join fields and editor access on job status. S.
  • English leaking into the Arabic panel in a dozen labels and in the redirects and nested-docs plugins. M.
  • listSearchableFields on materials is title only; add YouTube id, slug, WP id. S.
  • schedulePublish is enabled on articles and pages but no job runner exists; scheduled publishes never fire. S.
  • Media: alt not required; seven template image sizes. S.
  • Secure cookie flag on auth; .dockerignore; delete the unused Dockerfile and tsconfig includes for deleted files; drop pnpm-workspace.yaml and the --ignore-workspace flag; standalone size comment; .env.example still shows a Mongo URL; undocumented env vars (YOUTUBE_API_KEY, GEMINI_*, OLLAMA_*, PIPELINE_WORKER_NAME, WP_*).
  • Worker: treat HTTP 409 on complete as success; report repr(e) not the full traceback.
  • expectedPassages memoized forever including null.
  • Google Fonts render-blocking; self-host.
  • Two spellings of sheikh URLs from two encoders.
  • Docs drift: IPs and sslip hostnames repeated across pages; keep one hosts table and a verify check that documented commands exist.
  • Live preview config and generatePreviewPath target a frontend route that does not exist; GraphQL route still mounted while disabled.
  • Audio and video player with transcript-hit playback, cue highlighting and ?t= deep links, including for YouTube embeds.
  • Quizzes: two “coming soon” stubs.
  • Sheikhs directory page and series index page.
  • Live stream page; about and feedback pages; social links; RSS feed.
  • Transcript review workflow for editors, and a cue editor instead of raw JSON.
  • WordPress leftovers not redirected: /page/N/, /?p=, /author/*, /feed/.
  • Analytics and consent; Open Graph and JSON-LD; custom 500 page.
  • CI pipeline; error tracking; uptime checks.

Content of the materials index, not just its count; sentences and passages of unpublished material; cue shape; null-language transcripts; enum versus migration drift; sort honoured; an admin-created material reaching the index; the pure functions (search grouping, cue parsing, claim reclaim) without live services.