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/.
Summary
Section titled “Summary”| 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.
P0 status (2026-09-10)
Section titled “P0 status (2026-09-10)”| # | 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). |
P0 — must fix before launch
Section titled “P0 — must fix before launch”Search returns or hides the wrong things
Section titled “Search returns or hides the wrong things”- Unpublished material stays in semantic search.
src/search/sync.ts:216clears only the materials and segments indexes on unpublish or delete; sentences and passages keep the text, andenrich()returns the material withoverrideAccess. Republishing never re-adds segments becausestatusis not in the denormalized list. Fix: purge all four indexes on unpublish/delete; reindex on publish. S. - Admin-saved materials are indexed stale or not at all.
src/search/sync.ts:37reads the material withoutreq, outside the saving transaction: a new material is not found and never indexed; an edited title pushes the old title. Fix: passreq, asreindexMaterialSegmentsalready does. S. - Edited materials lose
language,year,titleFoldedin the index.sync.ts:53projects fewer fields than the bulk indexer (src/migrate/indexSearch.ts:129) andaddDocumentsreplaces the document, so an edited material drops out of title search and the year facet. Fix: one shared projection function. S. - Sorting by newest/oldest returns HTTP 500.
src/search/meili.ts:135sets ranking rules for segments withoutsort, so Meilisearch rejects the sort parameter. Fix: add'sort'. S. - Search filters are built from unvalidated request fields.
src/endpoints/search.ts:248interpolatestype,sheikhId,seriesId,since,languageinto Meilisearch filter strings; a crafted array rewrites the filter, and transcript scope has no published clause. Fix: allowlist enums,Number.isIntegeron ids. S. maxTotalHitsis not in code. The live index was raised by hand; a rebuild fromconfigureIndexessilently caps counts at 1,000. Fix: set it insrc/search/meili.ts. S.- Saving an English transcript deletes the Arabic one from search.
sync.ts:92looks up one transcript per material regardless of language and segment ids arematerial_index. Fix: key by language, index every transcript. M. - The semantic reindex runs unawaited before commit.
sync.ts:147fires in the background while the transaction is open and usually finds nothing. Fix: run after commit, or passreqand await. M.
Site features that are broken today
Section titled “Site features that are broken today”- Search hits play nothing.
frontend/src/pages/search.astro:304renders hits as play buttons, but no player exists and the handler only highlights. Fix: link each hit to the material with?t=startand honour it in the detail page. S. - Every homepage sheikh card is a 404.
frontend/src/pages/index.astro:300links by id where the route resolves by slug. Fix: return the slug from facets. S. - Stored XSS from CMS content.
frontend/src/pages/index.astro:201renders sheikh and series names withset:html. Fix: plain interpolation. S. Related:frontend/src/lib/lexical.ts:57allowsjavascript:links; allow only http(s), mailto and relative. S. - No fetch timeout anywhere.
frontend/src/lib/api.ts:64: a stalled API hangs every server render. Fix:AbortSignal.timeout(8000). S. - 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. - Sitemap points at localhost and the wrong domain.
frontend/src/pages/sitemap.xml.ts:28reads an env var that is never set;:18hardcodeshttps://kalelm.com; nositeinastro.config.mjs, so canonicals trust the Host header, which is cache-poisonable under the new edge rule. Fix:sitefrom env,PUBLIC_API_URL. S. - English pages search only English material.
frontend/src/pages/search.astro:47passes the locale as the language filter, so/en/searchsees 9 recordings and the English homepage chips return nothing. Fix: filter by language only when asked. S. - Article index 404s while every header and footer links to it.
frontend/src/pages/article/index.astro:16. Fix: empty state. S.
Safety and operations
Section titled “Safety and operations”- 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-apilike the frontend’s, plus a small per-IP bucket on/search. M. - Stale jobs are re-queued forever.
src/endpoints/pipeline.ts:259ignoresattemptson reclaim; a job that kills its worker burns GPU every 30 minutes. Fix: fail after three claims. S. - Deleting a material with a transcript fails.
transcripts.material_idis NOT NULL withON DELETE SET NULL. Fix: migration to cascade, plus an index purge. S. hasTranscriptis never backfilled by the migration despite the field’s comment; bulk imports skip the hook. Fix: oneUPDATE … SET has_transcript = EXISTS(…)at the end of the transcripts stage. S.- Git. Backend has one commit and 230 untracked files; the frontend has no repository. Fix: commit now. S.
- ESLint has never run.
eslint.config.mjs:13wraps an already-flat config in FlatCompat and crashes. The verified fix: importeslint-config-next/core-web-vitalsand/typescriptdirectly, drop@eslint/eslintrc. S. pnpm audit: sharp 0.34.2 has two high advisories. Bump to 0.35.4 or later. S.fillDurationoverwrites manual durations.src/hooks/fillDuration.ts:24checks the partialdataonly, so any partial update, including the transcript hook’s flag refresh, refetches and overwrites. Fix: consultoriginalDoc. S.- Media accepts SVG.
src/collections/Media.ts:50has no mime allowlist; an editor’s SVG with a script runs on the admin origin. Fix: allow jpeg, png, webp. S.
P1 — should fix soon
Section titled “P1 — should fix soon”Backend
Section titled “Backend”src/utilities/ttlCache.tskeys 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:56caches a returned{error}for five minutes. Throw instead. S.pipeline.ts:344: complete and heartbeat never check the job’sworkermatches the caller; one leakedCRON_SECRETcan write any running job’s transcript. Addand worker = $n. S.pipeline.ts:381: transcript create and job update are not one transaction. Wrap them. M.pipeline.ts:322: cue text length andstatssize are unbounded. Cap. S.- Postgres over the internet with
sslmode=no-verify. Pass the server certificate ascaand useverify-full. S. - Public search runs with the Meilisearch master key. Create a search-only key. S.
- Bearer comparisons use
===; usetimingSafeEqual. S. materials.audioUrlis free text fetched server-side and by workers. Validatehttps://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.cueshas no validation on admin edit; reuse the pipeline’svalidCues. S.transcripts.languageis nullable; make it required. S.groupByMaterialdiscardssort=newest|oldest;longestis 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, runaddContextfor keyword, page by material. M. - Sheikhs marked hidden are readable through the API and listed by facets and tree. S.
search-logswritten per anonymous query; sample or throttle. S.lib/pipelineWorker.ts:91passes the entire server environment to the spawned worker. Allowlist. S.- Stale drizzle snapshot; orphan
transcription,transcription_modelstables. Regenerate, drop. M.
Frontend
Section titled “Frontend”- Locale dropped on pagination and several link builders; page 2 of an English list lands in Arabic. Route through
localePath. S. degradedsearch 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.
Engineering
Section titled “Engineering”scripts/verify.tsconnects 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 runtsc, build,verify exports i18nandworker/test_worker.pyin CI. M.importMap.jsis stale and not regenerated on build. Addpayload generate:importmapto the build script. S.- No startup env validation; a deploy without
MEILI_HOSTpasses health and 500s on search. S. deploy-hangar.sh: token visible inps, missinghealthytreated as success, hand-maintained prune list; build fromgit archiveinstead. 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.mdis 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.
listSearchableFieldson materials is title only; add YouTube id, slug, WP id. S.schedulePublishis enabled on articles and pages but no job runner exists; scheduled publishes never fire. S.- Media: alt not required; seven template image sizes. S.
P2 — later
Section titled “P2 — later”- Secure cookie flag on auth;
.dockerignore; delete the unused Dockerfile and tsconfig includes for deleted files; droppnpm-workspace.yamland the--ignore-workspaceflag; standalone size comment;.env.examplestill 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. expectedPassagesmemoized forever includingnull.- 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
generatePreviewPathtarget a frontend route that does not exist; GraphQL route still mounted while disabled.
Unbuilt features
Section titled “Unbuilt features”- 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.
What the verify gate does not check yet
Section titled “What the verify gate does not check yet”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.