Source catalog operations
The catalog file, its validation, and the explicit admin sync that is the only path into Postgres.
ErmisAI keeps one canonical registry of news sources: the JSON file news-sources/catalog.v1.json,
committed to the app repository. Everything downstream — the tenant source picker, the ingestion
pipeline, the admin health screen — reads either that file or a Postgres table populated from it.
There is no admin UI for any of this. Editing the catalog is a code change; loading it into Postgres is a hand-issued POST. This page covers both, and the failure modes that make each one look like it worked when it did not.
The canonical file
news-sources/catalog.v1.json is a single document with six header fields and a sources array.
Its current header, read from the file:
| field | value |
|---|---|
schemaVersion | v1 |
catalogVersion | v1-5dacaf5c6a6cce84 |
catalogChecksum | 5dacaf5c6a6cce84a1483ec16538e79c5d216e4e49dcf3a08ac4376b65f6817d |
sourceCount | 1427 |
generatedAt | 2026-04-16T20:52:22.907Z |
metadata | { "normalizedAt": "2026-04-16T20:21:28.758Z" } |
catalogChecksum is sha256(JSON.stringify(normalizedSortedSources)) and catalogVersion is
literally "v1-" plus the first 16 hex characters of that checksum
(src/lib/sources/source-catalog-loader.ts:107-111). Both are derived. Never hand-write either one.
metadata is a free-form Record<string, unknown> (source-catalog-loader.ts:29); today it carries
only normalizedAt.
Per-source fields
All twelve fields are required and additionalProperties is false
(news-sources/catalog.schema.json:26-55, mirrored by zod at source-catalog-loader.ts:14-27 and
scripts/catalog/catalog-lib.ts:30-43).
| field | type | notes |
|---|---|---|
id | string | Primary key. Stable. Many carry legacy provenance prefixes (curated-opml-…, curated-csv-…) — those are just id strings now. |
name | string | Display name. Also the primary sort key. |
siteUrl | string | May be empty per schema; no entry is empty today. |
feedUrl | string | Trailing slashes stripped on normalize. Uniquely indexed in Postgres. |
type | rss | scraper | wire | |
tier | wire | tier1 | tier2 | tier3 | |
region | string | A display name, not an ISO code: "UK", "Czech Republic", "Global". |
language | string | Lowercase BCP-47-ish. |
categories | string[] | Canonical English labels, at least one. |
catalogOrigin | global | curated | |
ingestEnabled | boolean | |
active | boolean |
The file validates itself at runtime
createSourceCatalogDataset (source-catalog-loader.ts:120-153) parses the document, re-normalizes
every entry, recomputes the checksum, and then throws on any of three mismatches:
Source catalog sourceCount mismatch. Expected N, received M.
Source catalog checksum mismatch.
Source catalog version mismatch.The file is imported at build time (source-catalog-loader.ts:10) and bundled — it is never read
from disk at runtime — then memoized on globalThis.__ermisSourceCatalogCache.
A hand-edited catalog that was not re-checksummed breaks the whole app, not just the sync. Every
fallback read path funnels through listSourceCatalogDataset(), so a checksum mismatch takes down
the tenant source picker and the ingestion pipeline together. Always finish an edit with
pnpm catalog:build.
What is in the catalog today
Counts recomputed from the shipped file:
| dimension | breakdown |
|---|---|
type | rss 1332 · scraper 95 · wire 0 |
tier | tier3 767 · tier2 407 · tier1 233 · wire 20 |
catalogOrigin | curated 1415 · global 12 |
active | true 1293 · false 134 |
ingestEnabled | true 52 · false 1375 |
active and ingestEnabled | 13 |
| regions | 57 distinct (Global 638, US 61, Greece 27, France 26) |
| languages | 25 distinct (en 1015, es 43, de 38, fr 33) |
| categories | 55 distinct raw labels |
The 13 sources that are both active and ingest-enabled are aljazeera-all, bbc-business,
bbc-world, cnbc-world, dw-all, ft-home, npr-world, ie-silicon-republic,
guardian-business, guardian-world, nytimes-business, nytimes-technology, nytimes-world —
the twelve global-origin rows plus one curated exception.
That number is load-bearing. The global (unscoped) pipeline build calls
listSourceCatalogFromRepository({ ingestEnabledOnly: true })
(src/lib/services/rss-aggregation.ts:3571), so it polls exactly those 13 feeds. The other 1280
active sources are selectable by tenants and get polled inside that tenant's scoped build. The 134
inactive rows appear nowhere except /admin/sources.
README.md:234 says "Only global sources are ingest-enabled by default." That describes the code
default in withSourceDefaults (src/lib/db/source-catalog-repository.ts:207-219), which never
fires for file-sourced entries because every catalog entry declares ingestEnabled explicitly. The
shipped data has 52 ingest-enabled entries: 12 global and 40 curated.
active versus ingest_enabled
Two independent booleans that operators routinely conflate.
| flag | what it gates |
|---|---|
active | Visibility in the tenant-facing picker. getGlobalSourceCatalog() reads with the default active = true predicate (src/lib/platform/newsroom-preferences.ts:607), as does listSourceCatalog(). |
ingest_enabled | Whether the source is actually polled. Hard filter on the global build, and a kill switch that removes the source from tenant-scoped builds even when a tenant has selected it (src/lib/platform/local-platform-data.ts:987-1000). |
Custom tenant-added sources have no catalog row, so the tenant-scope filter treats them as enabled
(local-platform-data.ts:989-990).
Editing the catalog
Edit news-sources/catalog.v1.json directly
The file is the source of truth. pnpm catalog:build no longer builds from raw feeds — its own
docblock says so (scripts/catalog/build-canonical.ts:1-9), and news-sources/raw/ does not exist
in the repository despite the README mentioning it.
Normalize category strings if you added any
pnpm exec tsx scripts/catalog/normalize-categories.tsThere is no package-script alias. It rewrites every category to its canonical English label by
delegating to the runtime registry src/lib/i18n/categories.ts — deliberately, so the offline table
cannot drift from the one the app enforces
(scripts/catalog/normalize-categories.ts:17-28).
Re-sort and re-checksum
pnpm catalog:buildRe-reads the file, normalizes each entry, sorts by name then id, recomputes
catalogChecksum/catalogVersion/sourceCount, and rewrites the file in place. generatedAt is
preserved when present (build-canonical.ts:20-29).
Verify
pnpm catalog:validateRead-only. Prints source_count, catalog_version, catalog_checksum.
validateCatalogDocument (catalog-lib.ts:154-192) additionally enforces unique id and
unique feedUrl across the document — two invariants the runtime zod schema does not check.
Run it before committing.
Commit the file, then deploy, then sync
The bundled file only changes on deploy. The Postgres copy only changes when you call the sync route described below.
catalog:build and the runtime loader normalize language differently. The offline script does
entry.language.trim() || 'en' (catalog-lib.ts:112); the runtime applies normalizeSourceLanguage,
which lowercases, collapses _ to -, truncates past language-region, and falls back to und
(src/lib/sources/source-language.ts:21-38). Adding a source with language: "en-US" or "EN"
passes catalog:build and catalog:validate, then throws
Source catalog checksum mismatch. the first time the app loads the catalog. Every language value in
the shipped file is a plain lowercase two-letter tag; keep it that way.
Offline scripts
| command | script | effect |
|---|---|---|
pnpm catalog:build | scripts/catalog/build-canonical.ts | Rewrites the file: normalized, sorted, re-checksummed. |
pnpm catalog:validate | scripts/catalog/validate-canonical.ts | Read-only. Also enforces unique id and unique feedUrl. |
pnpm catalog:coverage | scripts/catalog/report-coverage.ts | Writes news-sources/coverage-report.json: region and category counts plus EU-27 and top-country coverage against a per-country target (--target, default 20). |
pnpm catalog:audit | scripts/catalog/audit-urls.ts | Pings every feedUrl and writes news-sources/audit-report.json. Defaults: concurrency 20, timeout 10000 ms, --feed-only. User agent ErmisAI-Auditor/1.0. |
| — | scripts/catalog/normalize-categories.ts | Canonicalizes category labels via the runtime registry. Run with pnpm exec tsx. |
news-sources/coverage-report.json is committed but stale: it reports sourceCount: 1428 and
catalogVersion: "v1-a4602caa47785112", and its catalogPath points at a directory that no longer
exists. Regenerate before quoting it. news-sources/audit-report.json is not committed at all, so
there is no stored record of current feed reachability.
Getting the catalog into Postgres
POST /api/admin/sources/catalog/sync is the only in-app path. Its own docblock states the contract:
Manual-only admin endpoint. Triggered by operator runbooks (see README + cutover scripts), not by automatic in-app flows.
There is no button, no cron, and no deploy step. Grep for admin/sources/catalog/sync across src/
returns zero hits — only docs/ai-newsroom-product-spec.md:758 and
scripts/db/cutover-to-supabase.sh:39 mention it.
Calling it
The route authenticates with auth() from Clerk and gates on
canAccessAdminSurface(appRole, 'sources') — super_admin, editor, or ops
(src/lib/auth/platform-roles.ts). There is no CRON_SECRET path and no API-key path, so the
request must carry a signed-in operator session. In practice: open /admin/sources in a browser as
an operator, then copy the request as cURL from devtools and change the method and path.
Responses (src/app/api/admin/sources/catalog/sync/route.ts:14-44):
| status | body |
|---|---|
| 200 | {"ok":true,"manualOnly":true,"trigger":"operator-runbook"} |
| 401 | {"error":"Unauthorized"} |
| 403 | {"error":"Forbidden"} |
| 500 | {"error":"Failed to sync source catalog","message":"Failed to sync source catalog"} |
What the route does
syncSourceCatalogRepositoryState()— resets the module-level memo flagshasSeededSourceCatalogandhasIngestedCuratedCatalogfirst, then seeds (src/lib/db/source-catalog-repository.ts:620-629). The reset exists because on a warm serverless instance the memo made this route a silent no-op that still repliedok.invalidateGlobalNewsroomSourceCatalogCache()— clears the process cache, the in-flight promise, and the Redis key.void scheduleSourceIconStamping()— fire-and-forget, not awaited.
ensureSourceCatalogSeeded() (source-catalog-repository.ts:504-550) is the actual work:
- Load the bundled dataset and stamp every entry with the file's
catalogVersion. upsertSourceCatalogEntries— dedupe incoming rows by feed URL (first wins), drop feed-URL conflicts, thenINSERT … ON CONFLICT (id) DO UPDATEin serial batches of 160 (SOURCE_CATALOG_UPSERT_BATCH_SIZE, line 13).icon_urlandicon_resolved_atare deliberately excluded from the update set so previously stamped icons survive a resync (comment at lines 382-383).deactivateStaleManagedSources— every row withcatalog_origin IN ('global','curated')whosecatalog_versiondiffers from the incoming version is setactive = false, ingest_enabled = false(lines 406-422). This is how removed sources retire.recordSourceCatalogDatasetVersion— upsert intosource_catalog_dataset_versionswith metadata{ schemaVersion, generatedAt, ...file metadata }.
ensureCuratedSourceCatalogIngested() is vestigial: it is a second memo flag that just calls
ensureSourceCatalogSeeded() (lines 552-572). There is no separate curated ingest path.
The offline alternative
pnpm db:seed-bootstrap (scripts/db/seed-bootstrap.ts, seedSourceCatalog at lines 263-307)
performs the same three steps in raw SQL. It is the only other writer of these tables.
db:seed-bootstrap does not invalidate the newsroom source-catalog cache. Changes take up to
SOURCE_CATALOG_CACHE_TTL_MS (5 minutes) plus the Redis TTL to appear in the tenant picker. Only the
admin sync route calls invalidateGlobalNewsroomSourceCatalogCache().
What the sync deliberately does not do
- It never deletes.
source_catalog_entriesgrows monotonically; retirement is a flag flip. - It never overwrites a stamped icon.
- It skips feed-URL conflicts.
feed_urlis uniquely indexed butidis the primary key, so an incoming entry whose feed URL already belongs to a different id is dropped, not upserted (filterFeedUrlConflicts, lines 277-303). One stdout line per distinct conflict signature:[source-catalog-repository] skipped N source entries because feed_url already belongs to a different source id. Re-iding a source while keeping its feed URL therefore does nothing until you delete or rename the old row by hand.
A failed sync still returns {"ok":true}. ensureSourceCatalogSeeded catches everything and
routes it to disableDbBackedCatalog('canonical source catalog sync', err)
(source-catalog-repository.ts:539-541), which latches the DB-backed catalog off for the process and
does not rethrow. The 500 branch in the route only fires for failures outside the seed. Your evidence
that a sync worked is the stderr line, the one-shot Sentry event, and a fresh row in
source_catalog_dataset_versions — not the HTTP status.
How reads degrade
listSourceCatalogFromRepository(options?) (source-catalog-repository.ts:574-618) is the single
read entry point. Options are includeInactive and ingestEnabledOnly; the default predicate is
active = true and ordering is tier ASC, name ASC. It falls back to the bundled file in three
distinct cases:
getDbContext()returnsnull— the DB module import failed, or the DB-backed catalog was already disabled in this process.- The read query throws →
disableDbBackedCatalog('catalog read query', err)→ fallback. - The query succeeds but returns zero rows (line 613) → fallback.
Case 3 is why the product works end to end on a deployment where the sync was never run.
Two consequences of the zero-row fallback. You cannot empty the catalog by truncating the table — the
bundled 1427 entries come straight back. And a sync that failed halfway and left the table empty is
indistinguishable, from the outside, from a healthy never-synced deployment. Check
source_catalog_dataset_versions and the row count in source_catalog_entries directly.
dbBackedCatalogDisabled is a per-process latch (source-catalog-repository.ts:110-132, 167-182). Once one catalog query fails, that Node process serves the bundled file for every
subsequent catalog read until the instance recycles, and emits exactly one Sentry event. Retryable
database errors set the latch without a Sentry event; anything else logs to stderr and reports once.
Callers
| caller | options | purpose |
|---|---|---|
src/lib/platform/newsroom-preferences.ts:607 | none (active-only) | The tenant-facing picker — all 1293 active sources. |
src/lib/services/rss-aggregation.ts:3571 | { ingestEnabledOnly: true } | The global pipeline build. |
src/lib/platform/sources/catalog.ts:27 | { ingestEnabledOnly: true } | Default monitored-source list for a tenantless scope. |
src/lib/platform/admin/dashboards.ts:98 | { includeInactive: true } | /admin/sources. |
src/lib/platform/local-platform-data.ts:992,1026 | none | Tenant-scope resolution and the ingest kill-switch filter. |
The tenant-facing cache
getGlobalSourceCatalog() (newsroom-preferences.ts:576) is three layers deep: a process-global
timed cache with SOURCE_CATALOG_CACHE_TTL_MS = 5 * 60_000 (line 186), an in-flight promise dedupe,
and the Redis key platform:cache:newsroom-source-catalog (line 188) on the same TTL.
If the database read throws and local-state fallback is not permitted, it raises a 503 with code
newsroom_source_catalog_unavailable and message "Newsroom source catalog is unavailable right now." Otherwise it falls back silently to the bundled file.
The two tables
Both live in src/lib/db/schema.ts.
source_catalog_entries (line 426)
id (PK, text), name, site_url, feed_url, type (source_type enum), tier (source_tier
enum), region, language (default 'en'), categories (jsonb string[]), catalog_origin
(plain text, default 'global' — not a pg enum), catalog_version (nullable text),
ingest_enabled (bool, default true), active (bool, default true), icon_url,
icon_resolved_at, last_fetched_at, last_fetch_status, last_error_message, created_at,
updated_at.
Indexes (lines 454-458): unique on feed_url; plain indexes on active, ingest_enabled,
catalog_origin, tier.
source_catalog_dataset_versions (line 462)
version (PK), catalog_checksum, source_count, metadata (jsonb), ingested_at; index on
ingested_at. One row per catalog version ever synced — the audit trail for "which file is this
database running".
Enums
export const sourceTypeValues = ['rss', 'scraper', 'wire'] as const // pgEnum 'source_type'
export const sourceTierValues = ['wire', 'tier1', 'tier2', 'tier3'] as const // pgEnum 'source_tier'Note that catalog_origin is not an enum, so the database will accept any string there. Only
'global' and 'curated' are produced by the sync, and only those two are swept by
deactivateStaleManagedSources.
Source health and icons
/admin/sources renders SourceHealthTable — read-only, no buttons, no empty state. Per source it
shows active/inactive, ingest on/ingest off, one of ok / failed / empty parse /
not polled, then Last polled: … · 24h articles: … · Error rate: …%, the feed URL, and either
the raw last error or Parser returned no RSS items or Atom entries.
listSourceHealth() (src/lib/platform/admin/dashboards.ts:96-124) merges the stored columns with
live health from the global feed cache. Status resolution order (lines 126-147): live fetchStatus
→ stored last_fetch_status → failed if any error message exists → not_polled.
Opening /admin/sources on a cold instance can trigger a synchronous full build — fetch every
ingest-enabled source, cluster, and AI-synthesize — because listSourceFetchHealth() calls
loadFeedCache() and a cache with zero stories builds synchronously and is awaited
(rss-aggregation.ts:3813-3826). That costs money and can take a long time. The same applies to
/admin/queues.
Custom tenant sources never appear here. updateSourceFetchStatus early-returns for any sourceId
starting with custom- (source-catalog-repository.ts:684-696) because there is no catalog row to
update; their health lives only in the tenant's scoped feed cache.
Icon stamping
icon_url is populated from the backlog active = true AND icon_url IS NULL, limit 200
(listSourceCatalogEntriesMissingIcon, source-catalog-repository.ts:656-682), with concurrency 4
(source-icon-stamper.ts:14-15).
The sync route fires scheduleSourceIconStamping() un-awaited — that runs one batch, and
concurrent calls share the same in-flight promise, so a single sync stamps at most 200 sources. To
clear a large backlog use pnpm db:backfill-source-icons, which calls
runSourceIconStampingUntilDrained and repeats batches until one stamps nothing new. That script
needs DATABASE_URL (or SUPABASE_DATABASE_URL) plus SUPABASE_URL and
SUPABASE_SERVICE_ROLE_KEY.
Stamping is currently mostly wasted work: SourceCatalogTable, SourcePicker and
MonitoredSourcesList all render <SourceFavicon> without passing iconUrl, so those three
surfaces round-trip the authenticated /api/sources/icon route regardless of what is stamped.
What tier actually does
const sourceTierWeights: Record<SourceTier, number> = { wire: 1, tier1: 0.9, tier2: 0.72, tier3: 0.55 }src/lib/services/rss-aggregation.ts:465-470. The weight is used in three places: ordering articles
within a cluster, the confidence score (averageTierWeight * 25 of a 100-point score alongside
unique-source count ×30, headline agreement ×25 and recency ×20), and the ordering of the eight
source articles fed into the synthesis prompt.
Tier does not affect fetch scheduling, concurrency, or which sources get polled. The tier descriptions the tenant UI shows ("Highest intake priority", "the lightest intake weight") describe the clustering and confidence weight, not a fetch priority.
Things in the data that have no implementation
type: 'scraper'— 95 rows, no scraper. The value is stored and displayed, but nothing branches on it:fetchFeedalways performs an RSS/Atom fetch offeedUrl. All 95 areactive: false, ingestEnabled: false, and for 93 of themfeedUrl === siteUrl(a homepage), so activating one would produce an empty parse.type: 'wire'— zero rows. The enum value exists and is unused. Do not conflate it withtier: 'wire', which has 20 rows (news agencies); all butft-homeareingestEnabled: false.listSourceCatalogVerticals()(source-catalog-loader.ts:195) andlistCategories()(src/lib/platform/sources/catalog.ts:40) are exported with zero callers anywhere insrc/,scripts/ortests/.- No admin UI for the sync, or for manual RSS refresh, state cleanup, or the erasure endpoints. All curl-and-runbook.
Test coverage
Thin, and worth knowing before you rely on CI to catch a catalog mistake:
tests/catalog-lib.vitest.ts— 2 cases: reads the canonical document, asserts specific curated North American and Greek entries exist.tests/source-catalog-repository.vitest.ts— 2 cases, both on feed-URL conflict filtering.tests/feed-validation.vitest.ts— 3 cases.
Nothing asserts the source count, the tier distribution, or the sync's stale-deactivation behaviour. A bad edit that still checksums correctly will ship.
Ingestion pipeline
What happens to the sources this catalog enables: fetch, cluster, synthesize.
The operations console
The /admin surfaces, the role matrix, and which screens cost money to open.
Database schema and runtime state
Where source_catalog_entries sits among the other 25 tables.
Choosing and adding sources
The tenant-facing side: the picker, country presets, and custom feeds.
