You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Findings are grouped by severity. Critical = must fix before launch (data breach / cross-tenant compromise / broken deploy pipeline). High = fix before or immediately after launch. Medium/Low = should be tracked, not launch-blocking.
Critical
1. Any logged-in user can read and overwrite every AI/Airtable/Meedan API key for the whole platform
The settings global stores providerCredentials[].apiKey, airtableAPIKey, and meedanAPIKey as plain type: "text" fields. Access control is read/update: ({ req }) => Boolean(req.user) — any authenticated user, regardless of role or tenant.
MaskedApiKeyField.tsx only masks the value in the admin React UI (getMaskedApiKeyPreview); it wraps a plain text field with no access.read restriction at the field or collection level. A logged-in user hitting GET /api/globals/settings (REST) or the equivalent GraphQL query gets every key back in plaintext — the masking provides zero server-side protection.
Failure scenario: a volunteer/editor account created just to manage one tenant's political-entity pages calls /api/globals/settings and exfiltrates the OpenAI/Anthropic/Google/etc. keys and the Airtable/Meedan tokens for the entire platform, or overwrites them (denial of service, billing abuse, data exfiltration via a swapped Airtable key).
Fix: add a field-level access: { read: () => false } (or an admin-only role check) on every credential field, and/or move these to server-only env vars entirely (the app already supports env-var fallback per .env.example), removing them from the editable/readable Payload schema.
2. Multi-tenant isolation is fully disabled — every authenticated user has cross-tenant access
The multi-tenant plugin's own default for userHasAccessToAllTenants is () => false (verified in node_modules/@payloadcms/plugin-multi-tenant/dist/index.js); this app overrides it to unconditionally true. That function gates the tenant-scoping access wrapper (addCollectionAccess/withTenantAccess) applied to pages, site-settings, political-entities, tenants, and to user management — with it hardcoded true, the tenant constraint is skipped for every authenticated user, and access falls back to each collection's own (unrestricted) access function.
Compounding this: Users has no roles field and no access block at all — there is currently no way to distinguish an admin from a low-privilege tenant editor. Payload's own default access when none is defined is Boolean(user) (node_modules/payload/dist/auth/defaultAccess.js), so every logged-in user can read/create/update/delete every other user account, and (per the above) every tenant's Pages, PoliticalEntities, and per-tenant SiteSettings.
Failure scenario: a Kenya-tenant editor account logs in and reads/edits Uganda's political entities, pages, and site settings, or promotes their own account by editing another user record — there is no server-side check preventing any of this today.
Fix: implement a real role/super-admin model (e.g. a roles field on Users and a tenant-membership array), make userHasAccessToAllTenants check that role instead of returning true, and add explicit access functions to Users, Pages, PoliticalEntities, and PromiseStatus that check both authentication and tenant membership.
3. No production deploy pipeline, and the only existing pipeline deploys unconditionally
File: .github/workflows/deploy-dev.yml (only Dokku-dev workflow; .github/workflows/ contains just this and claude.yml)
The workflow runs on every push to main: checkout → Docker build → push to Docker Hub → deploy to Dokku, with no lint, typecheck, or test step anywhere in the job (needs: is unused; there is exactly one job).
There is no deploy-prod.yml or tag/release-gated workflow at all.
Failure scenario: a broken PR merges to main and is live on (dev today, prod tomorrow if this file is copied under time pressure) within minutes, with no automated gate having run.
Fix: add a test job (lint + tsc --noEmit + pnpm test:int) that deploy depends on via needs:, and add a separate, approval-gated production workflow before launch.
4. Database migrations are never run automatically
Verified via grep across Dockerfile, docker/entrypoint.sh, and .github/workflows/deploy-dev.yml: payload migrate (defined only in package.json:16) is never invoked by the container entrypoint or by CI/CD.
Failure scenario: a PR changes a collection schema; the deploy succeeds; the running app now expects a schema the database doesn't have. This is a silent, easy-to-forget manual step at exactly the moment (launch, then ongoing feature work) where it matters most.
Fix: run pnpm migrate as a release-phase/pre-boot step in docker/entrypoint.sh before the app starts serving traffic.
High
5. DATABASE_URI has no fail-fast validation (inconsistent with PAYLOAD_SECRET)
File: src/payload.config.ts:32-34 throws a clear error if PAYLOAD_SECRET is missing, but line 74 does url: process.env.DATABASE_URI || "" with no equivalent guard.
Fix: mirror the PAYLOAD_SECRET guard for DATABASE_URI.
6. No health-check endpoint / container health probe
No /api/health (or similar) route exists under src/app/; no HEALTHCHECK in Dockerfile or docker-compose.yml. The entrypoint script does check Tika readiness internally (docker/entrypoint.sh:70-92) but doesn't expose that state externally.
Fix: add a cheap /api/health route (DB ping) and wire it into a Dokku/Docker health check.
7. AIExtractions.extractions[].uniqueId is not required, breaking the Meedan upload matching logic
File: src/collections/AIExtractions/index.ts:102-110; matched against in src/tasks/uploadToMeedan.ts (matches by uniqueId).
An extraction row saved without a uniqueId will never match during the upload-status update, leaving it permanently "pending" even after a successful upload.
Fix: mark uniqueIdrequired: true, or backfill/generate it in a beforeChange hook if it's meant to be system-generated.
8. No per-run concurrency guard on the AI extraction workflow/cron
The exportRows job already got a "stop pileup, make transaction-safe" fix (per recent commit a0422fc5); extractPromises/airtableWorkflow don't appear to have the same guard. If a run takes longer than the 1-minute cron interval, a second run starts against the same unprocessed documents.
Failure scenario: a slow AI provider makes one extractPromises run take 90s; the next cron tick starts a second run over the same documents, producing duplicate AI extractions.
Fix: apply the same "already queued/running" check used for exportRows to extractPromises/airtableWorkflow.
9. No timeout on AI provider calls, and no document-size cap before chunking
File: src/tasks/extractPromises.ts (generateText() calls around the chunk-processing loop; 14,000-char chunking with no upstream size limit)
A hung provider call blocks the task indefinitely (recovered only by the 2-hour stuck-job cleanup); an oversized or malicious document generates an unbounded number of chunks/API calls with no cost ceiling.
Fix: add a request-level timeout on generateText() calls, and a hard max-document-size check before chunking begins (reject/flag oversized documents instead of processing them).
10. Missing error.tsx / not-found.tsx at the frontend root
Verified: find "src/app/(frontend)" shows only layout.tsx and page.tsx at the top level — no error.tsx, no not-found.tsx anywhere under (frontend).
Failure scenario: an unhandled error on any public page (promise detail, entity page) surfaces Next.js's default error UI instead of a branded, graceful page; unmatched routes may not 404 cleanly.
Fix: add src/app/(frontend)/error.tsx and not-found.tsx.
11. Sequential (non-parallel) data fetching on hot public pages
File: src/app/(frontend)/[entitySlug]/promises/[promiseId]/page.tsx — getTenantNavigation() → getPoliticalEntityBySlug() → getPromiseById() → getPromiseUpdateEmbed() → getTenantSiteSettings() → resolveMedia() are awaited one after another; several of these don't depend on each other's output and could run concurrently via Promise.all.
Fix: parallelize the independent lookups; this directly affects TTFB on the highest-traffic page type at launch.
Medium
12. Webhook auth in meedan-sync uses non-constant-time comparison
File: src/app/api/meedan-sync/route.ts:310 — providedSecret !== configuredSecret. Contrast with the airtable-upload route, which uses crypto.timingSafeEqual.
Fix: use timingSafeEqual here too (pad/length-check first to avoid throwing on length mismatch).
13. Deleting a PromiseStatus or PoliticalEntity leaves denormalized export-row data stale
File: src/collections/PromiseStatus/hooks/index.ts:27-44 queues a re-sync on status delete but doesn't clear export rows that reference the deleted status first, so rows can carry a statusId pointing at nothing with a stale statusLabel.
Fix: delete/null out the affected export-row fields before (or as part of) queuing the re-sync.
14. AI provider credential resolved once per task, not re-checked if Settings change mid-run
File: src/tasks/extractPromises.ts — credentials are resolved at task start; if an admin changes the provider/key while a long run is in progress, the task keeps using the stale credential rather than failing fast or picking up the change.
Fix: acceptable to leave as-is if runs are short; otherwise re-resolve per document/batch.
15. No retry backoff on AI provider calls
File: src/tasks/extractPromises.ts (maxRetries: 3/4 on generateText()), no visible backoff configuration.
Failure scenario: a rate-limited or briefly-outaged provider gets hit with immediate retries across many documents/chunks in parallel, amplifying the outage and burning quota/cost.
Fix: confirm the ai SDK's built-in retry behavior includes backoff; if not, wrap with exponential backoff.
16. No application-level security headers
File: next.config.mjs — no headers() config for CSP, X-Frame-Options, Referrer-Policy, etc.
Fix: add a baseline security-headers config (Next.js headers() function), at minimum clickjacking (X-Frame-Options/frame-ancestors) and a reasonable CSP given third-party embeds (share widgets, media).
17. Sentry configured at 100% trace sampling with no PII scrubbing
Files: sentry.server.config.ts, sentry.edge.config.ts — tracesSampleRate: 1, enableLogs: true, no beforeSend. meedan-sync/route.ts:190 logs clientIp/userAgent, which could flow into Sentry breadcrumbs unfiltered.
Fix: lower tracesSampleRate for production via env, add a beforeSend hook to scrub IP/PII fields before events are sent.
18. Backfill migration isn't safely re-runnable if interrupted
File: migrations/20260423_000000_backfill_ai_extraction_export_rows.ts — deletes existing export rows for a batch, then insertMany(..., { ordered: false }). If interrupted between delete and insert, or if insert partially fails, re-running can leave a partial/inconsistent batch.
Fix: wrap the delete+insert per batch in a transaction, or add a dedupe/reconciliation check.
Low
Footer logo missing meaningful alt text — src/components/Footer/index.tsx:72 (secondary logo alt=""); should carry the org/site name for screen readers.
Promise status indicator relies on color alone — src/components/PromiseStatus/PromiseStatus.tsx; consider an icon/pattern alongside color for colorblind users.
Hardcoded English fallback strings in Hero (src/components/Hero/index.tsx:396-406) will show through on the French locale if a block's config omits copy — move defaults into locale-aware config.
ESLint: 0 errors, 39 warnings (mostly no-explicit-any / unused vars), concentrated in src/tasks/createPoliticalEntity.ts and src/tasks/downloadDocuments.ts — non-blocking cleanup.
No dead-letter/failure escalation for the job queue — failed jobs (hasError=true) aren't surfaced to Sentry; an operator has to check the Payload admin Jobs list manually to notice a stuck sync.
Stuck-job cleanup at a fixed 2-hour threshold (src/tasks/cleanupFailedJobs.ts) could delete a legitimately long-running large-document extraction rather than just abandoned jobs; consider a longer threshold or archiving instead of deleting.
Verified test/lint status (as of this review)
pnpm exec vitest run (int suite): 7 passed, 1 failed — the 1 failure (tests/int/api.int.spec.ts) requires a live MongoDB Atlas connection unreachable from this sandbox; not a code defect. 32 tests passed, 1 skipped.
pnpm lint: 0 errors, 39 warnings.
Dockerfile: multi-stage build, runs as non-root (nextjs, uid 1001), uses BuildKit --mount=type=secret correctly for database_uri/payload_secret/Sentry tokens (not baked into image layers), .dockerignore excludes .git/node_modules/.env*/tests — this part of the release story is solid.
Checked and ruled out (do not re-flag)
generateMetadata "missing await" on SEO calls — an initial pass flagged return buildSeoMetadata(...) (no await) inside generateMetadata in the promise-detail and catch-all pages as breaking SEO metadata. This is not a bug: generateMetadata is itself async, and return <promise> inside an async function is automatically flattened/awaited by the JS runtime before the function's own promise resolves — behaviorally identical to return await <promise>. No fix needed.
Public read: () => true on Documents, Promises, Media, AIExtractions, Partners — flagged by an initial pass as "unauthenticated data exposure." Given PromiseTracker's purpose (public accountability/transparency of promises and their evidence), public read access to this content is very likely intentional product behavior, not a defect. Worth a one-line confirmation with product ownership before launch, but not treated as a vulnerability here — the real problem is finding Fix pie chart events on hovering #1 above (writable/readable credentials), not readable public content.
Suggested fix order before launch
Lock down globals/Settings credential fields (finding Fix pie chart events on hovering #1) and fix the tenant-access bypass + add real roles (finding Update Readme #2) — these two together mean the app currently has no meaningful access control boundary.
Description
Findings are grouped by severity. Critical = must fix before launch (data breach / cross-tenant compromise / broken deploy pipeline). High = fix before or immediately after launch. Medium/Low = should be tracked, not launch-blocking.
Critical
1. Any logged-in user can read and overwrite every AI/Airtable/Meedan API key for the whole platform
src/globals/Settings/index.ts:12-15,src/globals/Settings/tabs/AITab.ts:487-502,AirtableTab.ts:21,MeedanTab.ts:21,src/globals/Settings/tabs/MaskedApiKeyField.tsxsettingsglobal storesproviderCredentials[].apiKey,airtableAPIKey, andmeedanAPIKeyas plaintype: "text"fields. Access control isread/update: ({ req }) => Boolean(req.user)— any authenticated user, regardless of role or tenant.MaskedApiKeyField.tsxonly masks the value in the admin React UI (getMaskedApiKeyPreview); it wraps a plain text field with noaccess.readrestriction at the field or collection level. A logged-in user hittingGET /api/globals/settings(REST) or the equivalent GraphQL query gets every key back in plaintext — the masking provides zero server-side protection./api/globals/settingsand exfiltrates the OpenAI/Anthropic/Google/etc. keys and the Airtable/Meedan tokens for the entire platform, or overwrites them (denial of service, billing abuse, data exfiltration via a swapped Airtable key).access: { read: () => false }(or an admin-only role check) on every credential field, and/or move these to server-only env vars entirely (the app already supports env-var fallback per.env.example), removing them from the editable/readable Payload schema.2. Multi-tenant isolation is fully disabled — every authenticated user has cross-tenant access
src/plugins/index.ts:64(userHasAccessToAllTenants: () => true),src/collections/Users/index.ts(fields: [], noaccess)userHasAccessToAllTenantsis() => false(verified innode_modules/@payloadcms/plugin-multi-tenant/dist/index.js); this app overrides it to unconditionallytrue. That function gates the tenant-scoping access wrapper (addCollectionAccess/withTenantAccess) applied topages,site-settings,political-entities,tenants, and to user management — with it hardcodedtrue, the tenant constraint is skipped for every authenticated user, and access falls back to each collection's own (unrestricted) access function.Usershas norolesfield and noaccessblock at all — there is currently no way to distinguish an admin from a low-privilege tenant editor. Payload's own default access when none is defined isBoolean(user)(node_modules/payload/dist/auth/defaultAccess.js), so every logged-in user can read/create/update/delete every other user account, and (per the above) every tenant's Pages, PoliticalEntities, and per-tenant SiteSettings.rolesfield onUsersand a tenant-membership array), makeuserHasAccessToAllTenantscheck that role instead of returningtrue, and add explicitaccessfunctions toUsers,Pages,PoliticalEntities, andPromiseStatusthat check both authentication and tenant membership.3. No production deploy pipeline, and the only existing pipeline deploys unconditionally
.github/workflows/deploy-dev.yml(only Dokku-dev workflow;.github/workflows/contains just this andclaude.yml)main: checkout → Docker build → push to Docker Hub → deploy to Dokku, with no lint, typecheck, or test step anywhere in the job (needs:is unused; there is exactly one job).deploy-prod.ymlor tag/release-gated workflow at all.mainand is live on (dev today, prod tomorrow if this file is copied under time pressure) within minutes, with no automated gate having run.testjob (lint +tsc --noEmit+pnpm test:int) thatdeploydepends on vianeeds:, and add a separate, approval-gated production workflow before launch.4. Database migrations are never run automatically
grepacrossDockerfile,docker/entrypoint.sh, and.github/workflows/deploy-dev.yml:payload migrate(defined only inpackage.json:16) is never invoked by the container entrypoint or by CI/CD.pnpm migrateas a release-phase/pre-boot step indocker/entrypoint.shbefore the app starts serving traffic.High
5.
DATABASE_URIhas no fail-fast validation (inconsistent withPAYLOAD_SECRET)src/payload.config.ts:32-34throws a clear error ifPAYLOAD_SECRETis missing, but line 74 doesurl: process.env.DATABASE_URI || ""with no equivalent guard.PAYLOAD_SECRETguard forDATABASE_URI.6. No health-check endpoint / container health probe
/api/health(or similar) route exists undersrc/app/; noHEALTHCHECKinDockerfileordocker-compose.yml. The entrypoint script does check Tika readiness internally (docker/entrypoint.sh:70-92) but doesn't expose that state externally./api/healthroute (DB ping) and wire it into a Dokku/Docker health check.7.
AIExtractions.extractions[].uniqueIdis not required, breaking the Meedan upload matching logicsrc/collections/AIExtractions/index.ts:102-110; matched against insrc/tasks/uploadToMeedan.ts(matches byuniqueId).uniqueIdwill never match during the upload-status update, leaving it permanently "pending" even after a successful upload.uniqueIdrequired: true, or backfill/generate it in abeforeChangehook if it's meant to be system-generated.8. No per-run concurrency guard on the AI extraction workflow/cron
src/workflows/airtableWorkflow.ts, job queue config insrc/payload.config.ts(defaultPAYLOAD_JOBS_CRON_SCHEDULE="* * * * *")a0422fc5);extractPromises/airtableWorkflowdon't appear to have the same guard. If a run takes longer than the 1-minute cron interval, a second run starts against the same unprocessed documents.extractPromisesrun take 90s; the next cron tick starts a second run over the same documents, producing duplicate AI extractions.exportRowstoextractPromises/airtableWorkflow.9. No timeout on AI provider calls, and no document-size cap before chunking
src/tasks/extractPromises.ts(generateText()calls around the chunk-processing loop; 14,000-char chunking with no upstream size limit)generateText()calls, and a hard max-document-size check before chunking begins (reject/flag oversized documents instead of processing them).10. Missing
error.tsx/not-found.tsxat the frontend rootfind "src/app/(frontend)"shows onlylayout.tsxandpage.tsxat the top level — noerror.tsx, nonot-found.tsxanywhere under(frontend).src/app/(frontend)/error.tsxandnot-found.tsx.11. Sequential (non-parallel) data fetching on hot public pages
src/app/(frontend)/[entitySlug]/promises/[promiseId]/page.tsx—getTenantNavigation()→getPoliticalEntityBySlug()→getPromiseById()→getPromiseUpdateEmbed()→getTenantSiteSettings()→resolveMedia()are awaited one after another; several of these don't depend on each other's output and could run concurrently viaPromise.all.Medium
12. Webhook auth in
meedan-syncuses non-constant-time comparisonsrc/app/api/meedan-sync/route.ts:310—providedSecret !== configuredSecret. Contrast with the airtable-upload route, which usescrypto.timingSafeEqual.timingSafeEqualhere too (pad/length-check first to avoid throwing on length mismatch).13. Deleting a
PromiseStatusorPoliticalEntityleaves denormalized export-row data stalesrc/collections/PromiseStatus/hooks/index.ts:27-44queues a re-sync on status delete but doesn't clear export rows that reference the deleted status first, so rows can carry astatusIdpointing at nothing with a stalestatusLabel.14. AI provider credential resolved once per task, not re-checked if Settings change mid-run
src/tasks/extractPromises.ts— credentials are resolved at task start; if an admin changes the provider/key while a long run is in progress, the task keeps using the stale credential rather than failing fast or picking up the change.15. No retry backoff on AI provider calls
src/tasks/extractPromises.ts(maxRetries: 3/4ongenerateText()), no visible backoff configuration.aiSDK's built-in retry behavior includes backoff; if not, wrap with exponential backoff.16. No application-level security headers
next.config.mjs— noheaders()config for CSP,X-Frame-Options,Referrer-Policy, etc.headers()function), at minimum clickjacking (X-Frame-Options/frame-ancestors) and a reasonable CSP given third-party embeds (share widgets, media).17. Sentry configured at 100% trace sampling with no PII scrubbing
sentry.server.config.ts,sentry.edge.config.ts—tracesSampleRate: 1,enableLogs: true, nobeforeSend.meedan-sync/route.ts:190logsclientIp/userAgent, which could flow into Sentry breadcrumbs unfiltered.tracesSampleRatefor production via env, add abeforeSendhook to scrub IP/PII fields before events are sent.18. Backfill migration isn't safely re-runnable if interrupted
migrations/20260423_000000_backfill_ai_extraction_export_rows.ts— deletes existing export rows for a batch, theninsertMany(..., { ordered: false }). If interrupted between delete and insert, or if insert partially fails, re-running can leave a partial/inconsistent batch.Low
src/components/Footer/index.tsx:72(secondary logoalt=""); should carry the org/site name for screen readers.src/components/PromiseStatus/PromiseStatus.tsx; consider an icon/pattern alongside color for colorblind users.Hero(src/components/Hero/index.tsx:396-406) will show through on the French locale if a block's config omits copy — move defaults into locale-aware config.no-explicit-any/ unused vars), concentrated insrc/tasks/createPoliticalEntity.tsandsrc/tasks/downloadDocuments.ts— non-blocking cleanup.hasError=true) aren't surfaced to Sentry; an operator has to check the Payload admin Jobs list manually to notice a stuck sync.src/tasks/cleanupFailedJobs.ts) could delete a legitimately long-running large-document extraction rather than just abandoned jobs; consider a longer threshold or archiving instead of deleting.Verified test/lint status (as of this review)
pnpm exec vitest run(int suite): 7 passed, 1 failed — the 1 failure (tests/int/api.int.spec.ts) requires a live MongoDB Atlas connection unreachable from this sandbox; not a code defect. 32 tests passed, 1 skipped.pnpm lint: 0 errors, 39 warnings.nextjs, uid 1001), uses BuildKit--mount=type=secretcorrectly fordatabase_uri/payload_secret/Sentry tokens (not baked into image layers),.dockerignoreexcludes.git/node_modules/.env*/tests — this part of the release story is solid.Checked and ruled out (do not re-flag)
generateMetadata"missing await" on SEO calls — an initial pass flaggedreturn buildSeoMetadata(...)(noawait) insidegenerateMetadatain the promise-detail and catch-all pages as breaking SEO metadata. This is not a bug:generateMetadatais itselfasync, andreturn <promise>inside an async function is automatically flattened/awaited by the JS runtime before the function's own promise resolves — behaviorally identical toreturn await <promise>. No fix needed.read: () => trueonDocuments,Promises,Media,AIExtractions,Partners— flagged by an initial pass as "unauthenticated data exposure." Given PromiseTracker's purpose (public accountability/transparency of promises and their evidence), public read access to this content is very likely intentional product behavior, not a defect. Worth a one-line confirmation with product ownership before launch, but not treated as a vulnerability here — the real problem is finding Fix pie chart events on hovering #1 above (writable/readable credentials), not readable public content.Suggested fix order before launch
globals/Settingscredential fields (finding Fix pie chart events on hovering #1) and fix the tenant-access bypass + add real roles (finding Update Readme #2) — these two together mean the app currently has no meaningful access control boundary.payload migrateinto the release path (findings Fix filters history + indicators #3, Add Issue and PR templates #4).DATABASE_URIvalidation, a health-check endpoint, anderror.tsx/not-found.tsx(findings Filters not updating on history pop #5, Homepage contribute section #6, Add about us page #10) — cheap, high-value production-hardening.