All your content feeds — RSS, podcasts, YouTube, and Bluesky — in one quiet, chronological place.
- Node.js >= 24 (see .nvmrc)
- npm
Install dependencies:
npm installSecrets are managed with dotenvx. The .env* files are
committed to git encrypted — the only plaintext copy of the keys is
.env.keys, which is gitignored. One private key per environment decrypts the
matching file:
| File | Environment | Private key (in .env.keys) |
|---|---|---|
.env |
local dev | DOTENV_PRIVATE_KEY |
.env.dev |
Netlify previews | DOTENV_PRIVATE_KEY_DEV |
.env.e2e |
e2e tests / CI | DOTENV_PRIVATE_KEY_E2E |
.env.production |
Netlify production | DOTENV_PRIVATE_KEY_PRODUCTION |
Only the database URL and the site URL (NUXT_SITE_URL) differ per environment;
the Clerk, Google, and Sentry values are identical across all of them. See
.env.example for the full variable reference and where to
obtain each value.
First-time setup: restore .env.keys from your password manager, then point
local dev at your own Neon branch so it never touches production data:
dotenvx set DATABASE_URL "<your-neon-dev-branch-url>" -f .env
dotenvx set NUXT_DATABASE_URL "<your-neon-dev-branch-url>" -f .envThe npm scripts wrap commands in dotenvx run, so npm run dev, npm run build, and npm run e2e decrypt the right file automatically. To change any
value later: dotenvx set VAR "value" -f <file>.
Losing
.env.keysmeans the encrypted files can't be decrypted. Keep it backed up in your password manager.
The app uses Drizzle ORM with a Neon serverless Postgres database.
The schema lives in server/db/schema.ts. Tables:
| Table | Description |
|---|---|
users |
One row per authenticated user, keyed by Clerk's userId |
feeds |
RSS and podcast feeds belonging to a user |
feed_items |
Individual items fetched from a feed |
integrations |
OAuth tokens for YouTube and Bluesky (encrypted at rest) |
user_settings |
Per-user reading preferences |
subscriptions |
Stripe billing state (customer, subscription, plan/status) per user |
integrations.accessToken / refreshToken / tokenSecret (YouTube OAuth
tokens, Bluesky JWTs, and the Bluesky app password) are encrypted with
AES-256-GCM before they're written — see
server/utils/crypto.ts and
docs/api-auth-storage.md for the design.
Requires TOKEN_ENCRYPTION_KEY (see .env.example for how to
generate one); reads tolerate legacy plaintext rows written before encryption
was added, but you should run the one-off backfill once per environment to
migrate them:
npm run tokens:backfill # local (.env)
dotenvx run -f .env.production -- node scripts/backfill-encrypt-tokens.tsPush the schema directly to Neon (useful for initial setup or during development):
npm run db:pushGenerate a SQL migration file from schema changes:
npm run db:generateApply pending migrations:
npm run db:migrateOpen Drizzle Studio (visual database browser):
npm run db:studiouseDb() is auto-imported in all Nitro server files:
// server/api/example.get.ts
export default defineEventHandler((event) => {
const db = useDb();
const user = event.context.user; // set by server/middleware/auth.ts
return db.query.feedItems.findMany({
where: (t, { eq }) => eq(t.feedId, user.id),
});
});Authentication is handled by Clerk via the @clerk/nuxt module.
@clerk/nuxtis registered innuxt.config.tsand automatically protects routes via its built-in middlewareapp/middleware/auth.global.ts— client-side route guard that redirects unauthenticated users to/loginand signed-in users away from/loginserver/middleware/auth.ts— runs on every server request; readsevent.context.auth()(set by Clerk) and upserts the user into Neon viagetOrCreateUser()event.context.useris then available in all downstream API route handlers
server/api/clerk/webhook.post.ts verifies Clerk's Svix signature (via @clerk/nuxt's verifyWebhook, wrapped in server/utils/clerk.ts so nothing else touches the SDK) and, on user.deleted, cascades the deletion into Neon: it purges billing, records a deletion tombstone, and deletes the users row (whose ON DELETE CASCADE removes feeds, feed items, integrations with their stored OAuth tokens, settings, and subscriptions). The cleanup logic is shared with the in-app deletion route in server/utils/accountDeletion.ts. Without this, deleting an account directly in Clerk would leave that data — including encrypted OAuth tokens — orphaned indefinitely.
To enable it, add a webhook endpoint in the Clerk Dashboard under Configure → Webhooks pointing at <your-app-url>/api/clerk/webhook, subscribed to the user.deleted event, then copy its Signing Secret into NUXT_CLERK_WEBHOOK_SIGNING_SECRET.
const { user } = useUser(); // reactive Clerk user object
const clerk = useClerk(); // ShallowRef<Clerk> — low-level access
const { isSignedIn } = useAuth(); // reactive auth stateconst clerk = useClerk();
clerk.value?.signOut({ redirectUrl: "/login" });The paid Pro plan (monthly or yearly, both with a 14-day free trial) is handled by Stripe Checkout and Billing.
server/utils/stripe.ts— the only file that imports thestripepackage. Wraps Checkout Session creation, Stripe customer creation, and webhook signature verification behind small functions so nothing else in the app touches the Stripe SDK directly.server/utils/subscriptions.ts— reads/writes thesubscriptionstable and maps a Stripe subscription status (trialing,active,past_due,canceled, etc.) to our ownplan("free" | "pro").server/api/billing/checkout.post.ts— authenticated route. Looks up (or creates) the user's Stripe customer, creates a Checkout Session for the requested interval with a 14-day trial, and returns the session URL.server/api/billing/webhook.post.ts— verifies the Stripe signature on every request, then persists subscription state oncustomer.subscription.created/.updated/.deleted. We listen to the subscription lifecycle events (notcheckout.session.completed) so renewals and cancellations stay in sync with one handler.server/api/billing/plan.get.ts— authenticated route returning the caller's current plan/status, used bySettingsAccount.vue.app/composables/useBilling.ts— client composable wrapping the two routes above;startCheckout()redirects the browser to the returned Checkout Session URL.
/pricing— the Pro plan CTAs start checkout. Signed-out visitors are routed through/login?redirect_url=...first and checkout resumes automatically after sign-in./settings/account— shows the caller's real plan (Free/Pro, trial end date) instead of a hardcoded label, with an "Upgrade to Pro" link back to/pricingwhen on the Free plan.
-
Create a Stripe account (test mode is fine for development) and grab the secret key from Developers → API keys.
-
Create one Product ("Pro") with two recurring Prices — monthly and yearly (yearly priced at a discount) — and copy each Price ID (not the Product ID).
-
Add a webhook endpoint at Developers → Webhooks pointing at
<your-app-url>/api/billing/webhook, subscribed tocustomer.subscription.created,customer.subscription.updated, andcustomer.subscription.deleted. Copy its signing secret. -
Set the four values below (see
.env.example) viadotenvx set VAR "value" -f <file>for each environment that needs them:Variable Purpose NUXT_STRIPE_SECRET_KEYServer-side Stripe API key NUXT_STRIPE_WEBHOOK_SECRETVerifies webhook requests are really from Stripe NUXT_STRIPE_PRICE_PRO_MONTHLYPrice ID for the monthly Pro plan NUXT_STRIPE_PRICE_PRO_YEARLYPrice ID for the yearly Pro plan NUXT_SITE_URLbasin's public base URL for billing redirects
Checkout and billing-portal redirect targets (success, cancel, and return URLs)
are built from NUXT_SITE_URL — basin's own public base URL — rather than the
request's Host header, so a forged Host can't hijack the post-billing
redirect. Set NUXT_SITE_URL per environment (e.g. http://localhost:3000
locally, the real domain in production).
Local Stripe CLI users can forward webhooks during development with stripe listen --forward-to localhost:3000/api/billing/webhook, which prints a temporary signing secret to use for NUXT_STRIPE_WEBHOOK_SECRET.
The app uses PGlite (@electric-sql/pglite) — a WASM build of Postgres running entirely in the browser, persisted via IndexedDB. This allows reads and writes to work without a network connection.
app/composables/useClientDb.ts— lazy-initialises a PGlite instance atidb://reader-appand applies the DDL migrations on first load. Returns a Drizzle client with the same query API as the server.app/db/schema.ts— client-side schema with three tables:feeds,feed_items, andsync_queue. No server-only tables (users, integrations, userSettings).app/composables/useSyncQueue.ts— queues offline mutations tosync_queue, then flushes them toPOST /api/syncwhen back online.app/plugins/sync.client.ts— registersonlineandvisibilitychangelisteners that trigger a flush automatically.server/api/sync.post.ts— applies queued mutations (markRead,star,save) to the Neon server DB.
// Read or write locally (works offline)
const db = await useClientDb();
const items = await db.query.feedItems.findMany({ ... });
// Queue an action for server sync
const { queueAction } = useSyncQueue();
await queueAction("markRead", { guid: item.guid });
// Flush manually (called automatically on reconnect)
const { flushSyncQueue } = useSyncQueue();
await flushSyncQueue();- PGlite is excluded from Vite's
optimizeDeps(exclude: ['@electric-sql/pglite']) to prevent Vite from trying to pre-bundle the WASM binary. - The client schema intentionally omits foreign key constraints — PGlite is a local cache, not the source of truth.
sync_queue.syncedAtisnullfor pending items; the flush loop stops on the first failure and retries on the next trigger.
Start the dev server at http://localhost:3000:
npm run devRun tests in watch mode:
npm testRun tests once (CI mode):
npm run test:ciOpen the Vitest UI:
npm run test:uiCheck for lint and formatting issues:
npm run lintAuto-fix issues:
npm run lint:fixBuild for production:
npm run buildPreview the production build locally:
npm run previewThe app deploys to Netlify automatically on push to main or dev. The build command runs tests before building:
npm run test:ci && npm run buildThe repo runs a deterministic security-scanner layer — secret detection and dependency vulnerability auditing — both locally and in CI.
gitleaks scans for committed secrets using the rules in .gitleaks.toml, which extend the bundled default ruleset with project-specific rules for Clerk secret keys (sk_live_/sk_test_) and Postgres/Neon connection strings that embed credentials.
- Locally: the
.husky/pre-commithook first runsdotenvx ext precommit(which blocks the commit if any tracked.env*file holds plaintext rather than encrypted values), then runsgitleaks git --stagedand blocks the commit on any finding. Install gitleaks first (instructions); if it is not installed the gitleaks step prints a warning and skips rather than failing. - In CI: the
secret-scanjob downloads the pinned gitleaks release and runs the binary directly — scanning the PR commit range on pull requests and the full history on pushes tomain. Any finding fails the build.
Run the staged scan manually:
gitleaks git --staged --config .gitleaks.toml --verboseThe dependency-audit CI job runs npm audit --json and pipes it through scripts/audit-gate.js, which fails the build only on high or critical advisories. Moderate and low advisories are printed as an informational summary without failing. Dependabot opens grouped weekly PRs for minor and patch updates.
A small set of high advisories live in deep transitive dependencies of the Stackbit visual-editor toolchain (@stackbit/* → @netlify/content-engine) and Google Cloud Storage, with no fix available short of a breaking major upgrade. Those specific advisories are suppressed via a documented allowlist in scripts/audit-allowlist.js — each entry names the advisory ID, the package, and the reason. The allowlist carries a reviewBy expiry: once it passes, the gate fails until the entries are re-checked for upstream fixes and the date is bumped. Any new high/critical advisory that is not on the allowlist still fails the build, so the gate keeps its teeth.
Run the gate locally:
npm audit --json | node scripts/audit-gate.jsHusky runs two hooks. A pre-commit hook runs the dotenvx plaintext-env guard and the gitleaks staged-secret scan (see Security scanning). A pre-push hook runs lint and tests. To skip hooks in CI, set HUSKY=0.