A Marvel/DC streaming platform built on Express, Postgres, and a React SPA (plus EJS for the auth-critical pages that stayed server-rendered), with a hand-rolled OAuth 2.1 authorization server, OpenFGA-based fine-grained authorization, an MCP server, and Google SSO.
- Features
- Architecture
- Authorization (OpenFGA)
- Requirements
- Setup
- Environment variables
- Running the app
- Useful scripts
- Deployment
- Catalog: a real Marvel/DC movie catalog with TMDB-sourced posters and trailers, search, pagination, and a per-user watchlist.
- Authentication: email + password signup/login (username auto-generated from the email), Google SSO with account linking by verified email, and session/device management (list active sessions with city-level location, sign out individually or everywhere).
- OAuth 2.1 authorization server: hand-written (no third-party IdP or OAuth library) authorize/token/introspect/revoke/JWKS endpoints, PKCE (S256 only), refresh token rotation with reuse detection, and RFC 7591 Dynamic Client Registration.
- MCP server: a Model Context Protocol server mounted behind the OAuth 2.1 server, for AI agents to interact with the catalog under scoped, user-consented access.
- Authorization: OpenFGA (self-hosted) enforces fine-grained, per-object permissions (who can discover, play, or publish a title) on top of the OAuth scope layer.
- Webhooks: signed, retried outbound webhook deliveries for third-party integrators.
- Theming: a light/dark mode toggle, persisted server-side via a cookie (no flash of the wrong theme on load).
flowchart TB
Browser["Browser"]
MCPClient["MCP client / AI agent"]
subgraph Render["Render (2 web services)"]
App["App service<br/>Express + EJS<br/>node src/index.js"]
FGA["OpenFGA service<br/>Docker (openfga/Dockerfile)"]
end
Neon[("Neon Postgres<br/>(shared by both services,<br/>separate table sets)")]
Google["Google OAuth"]
TMDB["TMDB API"]
Webhooks["Webhook receivers<br/>(third-party integrators)"]
Browser -- "HTTP: pages, login, catalog" --> App
MCPClient -- "OAuth 2.1 (DCR/PKCE) + MCP" --> App
App -- "app tables:<br/>accounts, titles, sessions, ..." --> Neon
App -- "Check / WriteTuples" --> FGA
FGA -- "store, tuple, ... tables" --> Neon
App -- "OIDC login" --> Google
App -- "fetch posters/trailers" --> TMDB
App -- "signed POST on title.published etc." --> Webhooks
The app and OpenFGA are two independently deployed Render services that happen to
share one Postgres instance — each owns its own tables (see
Authorization (OpenFGA) and Deployment).
Neither service can reach the other's tables; the only interaction between them is
the OpenFGA HTTP API (Check/WriteTuples) that the app calls at request time.
src/
app.js Express app factory (helmet, sessions, CSRF, rate limiting)
index.js Entry point, graceful shutdown, webhook delivery loop
config/ Environment variable loading/validation
db/ Postgres connection pool + transaction helper
repositories/ Only place raw SQL lives
services/ Business logic, transaction boundaries
controllers/ Request/response glue (controllers/api/ = JSON, for the SPA)
routes/ Express routers (routes/apiRoutes.js = the SPA's JSON API)
middleware/ CSRF, rate limiting, session/device tracking, view locals
auth/ OAuth 2.1 authorization server, Google SSO client
authz/ OpenFGA client + permission middleware
mcp/ Model Context Protocol server
webhooks/ Signing, delivery, retry logic
lib/serveSpa.js Serves the built React shell (public/app/index.html) for SPA routes
authz/model.fga OpenFGA authorization model (DSL)
openfga/Dockerfile Wraps the upstream openfga/openfga image for Render (see Deployment)
migrations/ Knex migrations (raw SQL, no ORM at runtime)
scripts/ One-off seed/backfill scripts
client/ React SPA source (Vite). Owns: home, movie, video player,
watchlist, account/sessions, account/webhooks, account/profile,
admin. Built into public/app/ (gitignored) by `npm run build`.
views/ EJS templates - login, signup, admin login, OAuth
consent/error only. Everything else moved to client/ (see
"UI: EJS + a React SPA" below).
public/ Static assets, CSS, and the built SPA bundle (public/app/)
The 5 pages above stay server-rendered EJS deliberately - they're redirect-heavy, security-sensitive (login, OAuth consent), and gain nothing from being client-rendered. Every other page is a React + React Router SPA, served by this same Express app (same origin, so session cookies and CSRF work with zero CORS changes):
npm run build(vite build, seevite.config.mjs) compilesclient/intopublic/app/, which lands under the app's existingexpress.static('/public')mount - no separate static route.- Each SPA route (
/,/movie/:id,/video/:id,/watchlist,/account/{sessions,webhooks,profile},/admin) is still owned by its original Express route, with its original auth/FGA guard intact - only the handler body changed, fromres.render(...)toserveSpa(src/lib/serveSpa.js). The SPA then fetches its data from/api/*(src/routes/apiRoutes.js), guarded the same way (requireApiLogin/requireApiAdmin, orrequireFgaPermission(..., {json: true})). - Render's build command must be
npm install --include=dev && npm run build, not a plainnpm install. Render setsNODE_ENV=productionfor the App service, which makes a barenpm installskipdevDependencies-vite(and@vitejs/plugin-react) live there, sonpm run build'svite buildfails withvite: not foundwithout--include=devforcing them in for this one install. See Deployment. public/css/style.cssis shared, unbundled, between the EJS pages and the React pages (linked fromclient/index.htmlas a plain<link>, not imported into the Vite module graph) - both render identical page chrome from one stylesheet.
Permissions are relationship-based (a Zanzibar-style model via OpenFGA), not a fixed role table. Three pieces:
- The model (
authz/model.fga) — the schema. Defines each type (user,account,plan,title, ...) and the relations it can have, including relations computed from other relations. - Tuples — the facts, e.g.
(user:7, owner, account:7)or(plan:free, required_plan, title:52). These live in OpenFGA's own Postgres tables (store,tuple), not the app's tables — see Deployment for how OpenFGA's datastore is configured. - Check — the runtime query: "does
user:7havecan_playontitle:52?" OpenFGA walks the model's relation graph combining tuples until it can answer.
define subscriber: [account#member]
define superseded_by: [plan]
define member: subscriber or member from superseded_by
member on a plan is true for a direct subscriber, or for a member of a plan
this one supersedes. If premium supersedes standard, a premium subscriber
automatically satisfies standard's member check with zero extra tuples — moving
someone up a tier is one tuple change, not a rewrite across every title.
define published: [user:*]
define entitled: member from required_plan
define can_discover: published or editor
define can_play: (published and entitled) or editor
published: [user:*]is a wildcard tuple — written once per title as(user:*, published, title:X), satisfied by every user. This is why the homepage can show posters to logged-out visitors:can_discoveronly needspublished.can_playis stricter — it also needsentitled, which resolves throughrequired_planand the plan ladder above. A title requiringstandarddenies a free-tier account even though they can still discover it (an upsell UX, not a bug).editor(an admin, or the platform'scontent_admin) bypasses both checks, so drafts are visible to staff before anyone else.
fgaSubjectForRequest/fgaSubjectForAdminRequestturn the session into an FGA subject string,user:<accounts.id>— the subject is the account row's id, not the username, so a new account needs its tuples written before anything works.requireFgaPermission(relation, objectFromReq, { tier, admin })wraps a route, builds the object string (e.g.title:${req.params.id}), and callscan().- Tiered fail policy:
tier: 'browse'(movie details) fails open on an FGA outage — a blip shouldn't take the homepage down.tier: 'strict'(playback, admin writes) fails closed — if OpenFGA is unreachable, the request is denied rather than risk serving a licensed video or letting a write through unchecked.
Applied in catalogRoutes.js: /movie/:id → can_discover at browse tier,
/video/:id → can_play at strict tier, /admin/add-movie → can_create_title at
strict tier with admin: true.
scripts/seed-fga-tuples.js— bulk-backfillsowner/subscriberfor every existing account andparent_platform/published/required_planfor every title.src/lib/accountLookup.js— writes the sameowner/subscribertuples the moment a new account is lazily created, so a fresh signup doesn't permanently failcan_playfor lack of tuples.- Adding a title via the admin panel does not yet write its
published/required_plantuples automatically — re-runseed-fga-tuples.jsafter adding titles outside the seed scripts, or it won't be playable/discoverable yet.
- Node.js (version pinned in
.nvmrc—nvm use) - Postgres 14+ (locally, or a free hosted instance — see Deployment)
- Docker (to run OpenFGA locally)
- A TMDB API key (for posters/trailers)
- A Google OAuth 2.0 client ID/secret (for Google SSO)
Clone the repository and install dependencies.
git clone <this-repo-url>cd Node-JS-OTT npm install
Copy the environment template and fill in real values.
cp .env.example .env
See Environment variables below for what each one is for.
Create a Postgres database and run migrations.
createdb comics_tv npx knex migrate:latest
Start OpenFGA locally with Docker, pointed at its own Postgres datastore (OpenFGA creates its own tables —
store,tuple, etc. — so it's fine to share the same database as the app, or use a separate one), then create a store and write the authorization model fromauthz/model.fga. Put the resulting store ID and model ID intoFGA_STORE_ID/FGA_MODEL_IDin.env.Seed the catalog and OpenFGA tuples.
node scripts/seed-catalog.js node scripts/seed-marvel-dc.js node scripts/seed-marvel-dc-batch2.js node scripts/seed-fga-tuples.js
| Variable | Purpose |
|---|---|
PORT | Port the app listens on |
DATABASE_URL | Postgres connection string |
SESSION_SECRET | Signs the session cookie |
NODE_ENV | development or production |
TMDB_API_KEY | Fetches posters/trailers from TMDB |
FGA_API_URL / FGA_STORE_ID / FGA_MODEL_ID | OpenFGA connection and model |
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET | Google SSO |
Never commit .env — it's excluded via .gitignore. .env.example documents every
variable with a placeholder value.
npm run build # builds the React SPA into public/app/ - required at least once
npm start # production
npm run dev # restarts the server on file changes (node --watch)
npm run dev:client # Vite dev server on :5173 with HMR, proxying everything else to# the Express app on $PORT (see vite.config.mjs's server.proxy) -# use this instead of rebuilding on every client changenpm start/npm run dev serve whatever's already built in public/app/ - they don't
build it, and won't notice if client/ has changed since the last build.
src/lib/serveSpa.js throws at boot (in production) if public/app/index.html is
missing entirely, rather than serving a broken page - so run npm run build at least
once after cloning, and again after pulling client-side changes.
The app listens on the port set in PORT (defaults to 1000).
| Script | Purpose |
|---|---|
scripts/backfill-accounts.js | Migrates legacy users/admins rows into the accounts/profiles tables |
scripts/seed-catalog.js | Seeds the base movie catalog |
scripts/seed-marvel-dc.js / seed-marvel-dc-batch2.js | Seeds the full Marvel/DC catalog |
scripts/fetch-tmdb-media.js / sync-catalog-media.js | Pulls posters/trailers from TMDB |
scripts/generate-clear-posters.js | Regenerates high-resolution poster assets |
scripts/backfill-credits.js | Backfills missing cast/crew from TMDB for titles that have neither |
scripts/seed-fga-tuples.js | Backfills OpenFGA authorization tuples for existing accounts/titles |
The app is deployed on Render, as two separate web services plus one shared external Postgres. Render has no managed MySQL, and its private-service tier (the usual way to self-host a database as a Docker container) requires payment info even at $0 — so both services point at a free external Postgres instead (Neon here, no card required):
- App: https://node-js-ott-6.onrender.com/ — Node native runtime, builds from this
repo's root, build command
npm install --include=dev && npm run build, start commandnode src/index.js. The build command runsvite build, producing the React SPA bundle atpublic/app/(gitignored — never commit it) thatsrc/lib/serveSpa.jsserves for the migrated pages (see Architecture). Two ways this fails if the build command is wrong: a plainnpm install(nonpm run buildat all) deploys a broken app -serveSpa.jsfails loudly at boot withSPA bundle not foundrather than serving stale or missing content.npm install && npm run buildwithout--include=devfails at the build step itself withvite: not found- Render setsNODE_ENV=production, which makesnpm installskipdevDependencies(vite/@vitejs/plugin-reactlive there), so they're missing whenvite buildtries to run. - OpenFGA: https://comics-tv-openfga.onrender.com/ — Docker runtime, builds from
openfga/Dockerfilewith root directoryopenfga. The upstreamopenfga/openfgaimage has no default command (its entrypoint alone just prints help and exits), and Render's image-runtime service type has no reliable way to override a container's command via its dashboard or CLI — so this repo's Dockerfile just wraps the upstream image withCMD ["run"]baked in. Schema migrations (openfga migrate) are run as a one-off against the same image/datastore rather than automatically on every deploy. - Both services set
OPENFGA_METRICS_ENABLED=false/ rely on the app'sPORTenv var being explicit — OpenFGA exposes three ports (HTTP API, gRPC, Prometheus metrics), and Render's automatic port detection can pick the wrong one if metrics are left on. DATABASE_URL(app) andOPENFGA_DATASTORE_ENGINE=postgres+OPENFGA_DATASTORE_URI(OpenFGA) point at the same Neon database — OpenFGA creates its own tables (store,tuple, etc.) that don't collide with the app's.- Set every other variable from Environment variables in each service's Render environment settings.
- Add both your local (
http://localhost:<PORT>/auth/google/callback) and production (https://node-js-ott-6.onrender.com/auth/google/callback) redirect URIs to the Google Cloud OAuth client's authorized redirect URIs list.