Skip to content
Sashank-SinghPublic

About

A new truth seeking editorial news company

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

TimesOfSF

A San Francisco-focused news reader with a React frontend, an Express backend, a Postgres article store, and an automated LLM-powered news pipeline.

In production, the Docker/Coolify app serves both the public SPA and /api/* from the same Express process.

Architecture

Layer Role
src/ Public React UI: articles, events, search, reader, TTS controls
server/ Express API, article pipeline, SEO HTML injection, Kokoro TTS
Postgres Articles, pipeline runs, source attempts, events, newsletter subscribers, knowledge state
Docker/Coolify Production runtime for the single Express + static app container

Requirements

  • Node.js 22 for production parity with the Docker image
  • Postgres connection string in DATABASE_URL
  • Fireworks or Gemini API key
  • Python/Kokoro dependencies are installed by the Docker image for production TTS

Local Setup

  1. Install dependencies:
npm ci
  1. Create .env from .env.example and set at least:
DATABASE_URL="postgresql://..."
LLM_PROVIDER=fireworks
FIREWORKS_API_KEY="..."
OWNER_CLI_SECRET="change-me-min-12-chars"
  1. Run the backend:
npm run server:dev
  1. Run the frontend in another terminal:
npm run dev

The dev frontend calls http://localhost:8787 when VITE_API_URL is unset.

  1. Smoke-test the production server locally:
npm run build
NODE_ENV=production npm run server:start

Open http://localhost:8787.

Scripts

Command Purpose
npm run dev Vite dev server
npm run server:dev Express dev server with tsx watch
npm run server:start Production Express entry
npm run build Build the Vite app into dist/
npm run lint Typecheck app and shared code
npm run lint:server Typecheck server code
npm run admin:articles Owner CLI for article edits

Environment

Full examples live in .env.example.

Required

Variable Notes
DATABASE_URL Postgres URL, SSL recommended for hosted DBs
LLM_PROVIDER fireworks or gemini
FIREWORKS_API_KEY or GEMINI_API_KEY Required for article synthesis
OWNER_CLI_SECRET Owner/admin secret for protected operational routes

Deployment URLs

Variable Notes
APP_URL Public site URL used for metadata, sitemap, and canonical links
PORT Express port, default 8787
VITE_API_URL Leave unset for default Coolify single-container deploy
CORS_ALLOWED_ORIGINS Only needed for split frontend/API deployments

For the default Coolify deployment, do not set VITE_API_URL; the browser should use same-origin /api.

Pipeline Scheduling

The app no longer depends on Inngest. Coolify/private-server production uses the internal timer by default:

INTERNAL_PIPELINE_INTERVAL_MS=600000
PIPELINE_COALESCE_MS=420000
DISABLE_INTERNAL_PIPELINE_CRON=false

Behavior:

  • The first pipeline tick starts about 30 seconds after server boot.
  • Later ticks run every INTERNAL_PIPELINE_INTERVAL_MS.
  • A single-flight guard prevents overlapping pipeline runs.
  • PIPELINE_COALESCE_MS skips near-duplicate timer/external-cron attempts.
  • A tick attempts to publish; it does not guarantee a new article every time.

Optional external cron:

CRON_SECRET="change-me-long-random-secret"

Then call:

GET /api/cron/pipeline?secret=...

Manual pipeline runs also accept CRON_SECRET or OWNER_CLI_SECRET.

Newsletter Delivery

Newsletter signup stores the email and selected categories in Postgres. Actual email delivery is disabled until a Postal mail server is configured:

POSTAL_API_URL="https://email.example.com"
POSTAL_API_KEY="postal-server-api-key"
NEWSLETTER_FROM="Times of SF <news@timesofsf.com>"
NEWSLETTER_REPLY_TO="hello@timesofsf.com"
NEWSLETTER_DIGEST_HOURS="8,20"
NEWSLETTER_TIMEZONE="America/Los_Angeles"
DISABLE_NEWSLETTER_SENDER=false

POSTAL_API_URL/POSTAL_API_KEY come from a Postal mail server whose sending domain (matching NEWSLETTER_FROM) has been added and DNS-verified (SPF/DKIM/MX) in that Postal instance's admin UI.

Behavior: two digest emails a day (not a rolling per-article send), one per hour listed in NEWSLETTER_DIGEST_HOURS (local time in NEWSLETTER_TIMEZONE) — the earliest configured hour is the "morning" digest, any other is "night."

  • Each digest targets 7-10 stories, picked by the LLM (same Fireworks model as article synthesis) for significance, each with a one-line "why it matters." Candidates are drawn from a rolling 30-day pool rather than "since the last digest," so a quiet news day still has enough to send — it's per-subscriber delivery tracking, not the window, that keeps morning and night from repeating.
  • Tech and Funding are treated as priority beats: wherever they appear in the ranked pool, they're sorted above every other category, both by prompt instruction and by a deterministic sort afterward (not left to the LLM's discretion alone).
  • Personalized to each subscriber's saved topic preferences (categories only apply here); a subscriber with no preferences gets the universal top stories across all categories, unfiltered.
  • newsletter_digest_runs makes each day/slot idempotent — a digest is never sent twice, even if the check fires more than once in the same hour.
  • newsletter_deliveries records each subscriber/article pair so nothing already sent to someone is repeated in a later digest, no matter how far back it was.
  • Each email includes a tokenized unsubscribe link.
  • The internal scheduler checks every 60s for a due hour. POST /api/cron/newsletter (CRON_SECRET or OWNER_CLI_SECRET) checks the same way, or pass ?slot=morning/?slot=night to force a specific digest on demand (still respects the once-per-day guard).

TTS

TTS uses local Kokoro in production. The Docker image installs kokoro-onnx, espeak-ng, and downloads model files.

Recommended persistent cache on Coolify/private servers:

TTS_CACHE_DIR="/var/lib/timesofsf/tts-cache"

Audio is stored as WAV:

  • Chunk cache: TTS_CACHE_DIR/<hash>.wav
  • Full-article cache: TTS_CACHE_DIR/articles/<article-key>.wav

TTS routes have per-IP rate limits and size caps. The persistent worker has a timeout so a failed Python process cannot leave the queue stuck.

TTS_RATE is legacy from Edge TTS and is not used by the current Kokoro path.

Protected Routes

Use one of these auth styles:

  • Authorization: Bearer <secret>
  • x-admin-secret: <OWNER_CLI_SECRET>
  • x-cron-secret: <CRON_SECRET>
  • ?secret=<secret> where supported
Route Auth
POST /api/admin/article-from-url OWNER_CLI_SECRET
POST /api/pipeline/run OWNER_CLI_SECRET or CRON_SECRET
GET /api/cron/pipeline OWNER_CLI_SECRET or CRON_SECRET
POST /api/cron/newsletter OWNER_CLI_SECRET or CRON_SECRET
GET /api/runs/:runId OWNER_CLI_SECRET
POST /api/posts/:id/expand OWNER_CLI_SECRET

Public routes used by the frontend:

  • GET /api/posts
  • GET /api/events
  • POST /api/newsletter/subscribe with { email, preferences, source }; preferences accepts one or more article categories such as Tech, Civics, Funding, Culture, Transit, Food, or Events. Politics/Political are saved as Civics.
  • POST /api/tips with { postSlug?, kind, message, contactEmail? } — reader tips/corrections, rate-limited.
  • POST /api/auth/signup, POST /api/auth/login, POST /api/auth/logout, GET /api/auth/me, PUT /api/auth/preferences — password-based accounts (tosf_session httpOnly cookie), rate-limited.
  • GET /api/auth/verify?token=, POST /api/auth/resend-verification — email ownership confirmation. Verification is not a gate: accounts work immediately and this only records that the address is real.
  • POST /api/auth/forgot-password, POST /api/auth/reset-password — emailed reset links (1h expiry, single use). Reset tokens are stored as SHA-256 hashes, and a successful reset signs the user out everywhere. forgot-password always returns the same response so it cannot be used to enumerate accounts.
  • GET/POST/DELETE /api/bookmarks(/:postId) — requires a signed-in session.
  • GET /health
  • TTS playback/download routes, rate-limited

Coolify Notes

The Dockerfile builds the frontend and starts Express with:

npm run server:start

Set runtime environment variables in Coolify, not build-only variables:

  • DATABASE_URL
  • LLM_PROVIDER
  • FIREWORKS_API_KEY or GEMINI_API_KEY
  • OWNER_CLI_SECRET
  • APP_URL
  • TTS_CACHE_DIR
  • optional CRON_SECRET

After deploy, check:

https://your-domain.com/health

Expected scheduler shape:

{
  "internalPipelineCron": {
    "active": true,
    "intervalMs": 600000
  }
}

Analytics (PostHog)

The site uses self-hosted PostHog for first-party analytics (pageviews, article views, sessions, referrers). It is loaded lazily from the browser with no npm dependency, so if PostHog is down or not configured, the site is completely unaffected.

1. Deploy PostHog in Coolify

PostHog is a large self-hosted stack (ClickHouse, Kafka, Postgres, Redis). The supported way to run it is the official hobby deploy — it provisions all backing services with one command on an Ubuntu VM (4 vCPU / 16 GB RAM recommended):

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/posthog/posthog/HEAD/bin/deploy-hobby)"

Or install the PostHog Docker image (posthog/posthog:latest) in Coolify as a separate application and set its runtime env vars per the PostHog self-host docs. Either way, you end up with:

  • a public URL, e.g. https://posthog.yourdomain.com
  • a project API key (phc_...) from Settings → Project → API keys

Note: self-hosted PostHog is officially unsupported by PostHog and should run on the latest image. If you prefer zero infrastructure, PostHog Cloud's free tier (1M events/mo) is also compatible — just point VITE_POSTHOG_HOST at https://us.i.posthog.com.

2. Point the site at PostHog

Set these as build variables in Coolify (the client bakes them in at build time):

VITE_POSTHOG_HOST="https://posthog.yourdomain.com"
VITE_POSTHOG_KEY="phc_..."

Redeploy. The frontend now sends:

  • $pageview on every route change (SPA navigation included)
  • article_viewed with slug, title, and category when an article is opened
  • identify when a user signs in or logs in (so PostHog can show signed-in traffic)

View dashboards in PostHog: Web analytics for pageviews/visitors, Events for article_viewed, and Funnels to track signup → read paths.

Validation

Run before pushing:

npm run lint
npm run lint:server
npm run build
npm audit --audit-level=moderate

Tech Stack

React 19, Vite 6, Tailwind 4, Express, Postgres, Fireworks/Gemini, Kokoro TTS, TypeScript.

License

Private project.

About

A new truth seeking editorial news company

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages