Skip to main content

Platform App

The Platform App is a Next.js 14+ application serving three roles:

  1. Viewer Portal — Public-facing token entry and HLS video player
  2. Admin Console — Protected event and token management at /admin
  3. API Backend — REST endpoints for token validation, JWT lifecycle, and admin CRUD

Directory Structure

platform/
├── prisma/
│ └── schema.prisma # Database schema (Event, Token, ActiveSession)
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── page.tsx # Viewer Portal entry page
│ │ ├── layout.tsx # Root layout
│ │ ├── globals.css # Tailwind CSS + global styles
│ │ ├── admin/ # Admin Console pages
│ │ └── api/ # API Routes
│ │ ├── admin/ # Admin CRUD endpoints (session auth required)
│ │ │ ├── login/ # POST — admin login
│ │ │ ├── logout/ # POST — admin logout
│ │ │ ├── session/ # GET — check admin session
│ │ │ ├── events/ # GET/POST + /:id (GET/PUT/DELETE + actions)
│ │ │ ├── tokens/ # GET, PATCH /:id/revoke|unrevoke, bulk-revoke
│ │ │ └── dashboard/ # GET — dashboard stats
│ │ ├── tokens/ # POST /validate — public token validation
│ │ ├── playback/ # JWT refresh, heartbeat, release
│ │ ├── events/ # GET /:id/status — public event status
│ │ └── revocations/ # GET ?since= — internal (HLS server polling)
│ ├── components/
│ │ ├── ui/ # 9 shadcn/ui components
│ │ ├── player/ # 9 video player components
│ │ ├── admin/ # 6 admin components
│ │ └── viewer/ # 5 viewer flow components
│ ├── hooks/ # 6 custom React hooks
│ ├── lib/ # 10 utility modules
│ └── generated/prisma/ # Prisma client (auto-generated)
├── package.json
└── tsconfig.json

App Router Pages and Routes

Public Pages

RouteComponentDescription
/page.tsxViewer portal — token entry → player flow
/adminadmin/page.tsxAdmin console (redirects to login if unauthenticated)

Viewer Flow

The viewer experience is a single-page flow managed by React state:

TokenEntry → (validate) → PlayerScreen / PreEventScreen / AccessEnded / ErrorMessage
  1. TokenEntry — Input field for 12-char access code
  2. PlayerScreen — Full HLS player with all controls
  3. PreEventScreen — Shown when event hasn't started yet (polls status every 30s)
  4. AccessEnded — Shown when token or event has expired
  5. ErrorMessage — Shown for validation errors (revoked, in-use, etc.)

API Route Patterns

Public Endpoints

These require no authentication — they are accessed by viewers:

MethodPathPurpose
POST/api/tokens/validateSubmit access code → get JWT + event info
POST/api/playback/refreshRefresh JWT using current JWT as Bearer auth
POST/api/playback/heartbeatKeep session alive (every 30s)
POST/api/playback/releaseRelease session on player close
GET/api/events/:id/statusGet event status (not-started/live/ended/recording)

Admin Endpoints

All admin endpoints require an iron-session cookie set by /api/admin/login:

MethodPathPurpose
POST/api/admin/loginAuthenticate with password
POST/api/admin/logoutDestroy admin session
GET/api/admin/sessionCheck if admin is authenticated
GET/api/admin/eventsList all events
POST/api/admin/eventsCreate event
GET/api/admin/events/:idGet event details
PUT/api/admin/events/:idUpdate event
DELETE/api/admin/events/:idDelete event (cascades tokens)
PATCH/api/admin/events/:id/activateActivate event
PATCH/api/admin/events/:id/deactivateDeactivate event
PATCH/api/admin/events/:id/archiveArchive event
PATCH/api/admin/events/:id/unarchiveUnarchive event
GET/api/admin/events/:id/tokensList tokens for event
POST/api/admin/events/:id/tokensGenerate tokens for event
GET/api/admin/events/:id/tokens/exportExport tokens as CSV
GET/api/admin/tokensList all tokens (with filters)
PATCH/api/admin/tokens/:id/revokeRevoke a token
PATCH/api/admin/tokens/:id/unrevokeUnrevoke a token
POST/api/admin/tokens/bulk-revokeBulk revoke tokens
GET/api/admin/dashboardDashboard statistics

Internal Endpoint

MethodPathAuthPurpose
GET/api/revocations?since=X-Internal-Api-Key headerReturns revocations and event deactivations since timestamp

Response Format

All API routes follow a consistent JSON format:

// Success
{ data: T }

// Error
{ error: "Human-readable error message" }

HTTP status codes are used semantically:

StatusMeaning
200Success
201Created
400Bad request (invalid input)
401Unauthorized (invalid/missing token or session)
403Forbidden (revoked token, deactivated event)
404Not found
409Conflict (token already in use on another device)
410Gone (token or event expired)
429Too many requests (rate limited)

Database Layer

Prisma ORM

The database is managed via Prisma with the schema at platform/prisma/schema.prisma. In development, SQLite is used; production uses PostgreSQL.

# Initialize or migrate the database
npx prisma migrate dev

# Generate Prisma client after schema changes
npx prisma generate

# Open Prisma Studio (GUI database browser)
npx prisma studio

The Prisma client is instantiated as a singleton in platform/src/lib/prisma.ts to avoid exhausting connections during development hot-reloads.

Migrations

Prisma migrations are stored in platform/prisma/migrations/. To create a new migration:

cd platform
npx prisma migrate dev --name describe-your-change
tip

Always run npx prisma migrate dev after pulling changes that modify schema.prisma. This ensures your local database schema matches the codebase.

Authentication Flow (Admin)

Admin authentication uses bcrypt password verification and iron-session encrypted cookies.

1. POST /api/admin/login { password: "..." }
2. Server: bcrypt.compare(password, env.ADMIN_PASSWORD_HASH)
3. If match: iron-session sets encrypted HTTP-only cookie
4. Cookie: Secure, SameSite=Strict, 8-hour expiry
5. Subsequent requests: iron-session decrypts cookie → authenticated
note

There is a single admin password for the entire application, stored as a bcrypt hash in the ADMIN_PASSWORD_HASH environment variable. The env.ts module reads this hash directly from the .env file to avoid Next.js $ character expansion issues with bcrypt hashes.

Token Validation Flow

When a viewer submits an access code:

POST /api/tokens/validate { code: "Ab3kF9mNx2Qp" }

1. Rate limit check (5/min per IP)
2. Sanitize input (trim, alphanumeric-only check)
3. Database lookup: Token + associated Event (by code)
4. Validate:
a. Token exists? → 401 "Invalid access code"
b. Token expired? (expiresAt < now) → 410 "Access code has expired"
c. Token revoked? → 403 "Access code has been revoked"
d. Event active? → 403 "This event is not currently available"
5. Check for active session (single-device enforcement):
a. Active session exists AND not timed out? → 409 "Access code is in use"
b. Stale session? → Clean up and proceed
6. Create ActiveSession record (generates UUID session ID)
7. Mark token as redeemed (redeemedAt, redeemedIp) if first use
8. Mint JWT via jose library:
- sub=code, eid=eventId, sid=sessionId, sp=/streams/:eventId/
- 1-hour expiry (JWT_EXPIRY_SECONDS = 3600)
9. Return: { event: PublicEventInfo, playbackToken, playbackBaseUrl, streamPath, expiresAt, tokenExpiresIn }

:::info Dynamic playbackBaseUrl The playbackBaseUrl returned to the browser is dynamically derived from the incoming request's Host header using getHlsBaseUrl() in lib/env.ts. This replaces the hostname in HLS_SERVER_BASE_URL with the request's hostname while preserving the HLS server port. This ensures LAN and remote clients receive a reachable HLS server URL without manual configuration. :::

JWT Refresh Flow

The player refreshes its JWT every 50 minutes (before the 60-minute expiry):

POST /api/playback/refresh
Authorization: Bearer <current-JWT>

1. Rate limit check (12/hour per token code)
2. Verify current JWT signature and extract claims
3. Look up token by claims.sub (access code)
4. Validate token is still valid (not revoked, not expired, event active)
5. Verify session still exists (claims.sid matches active session)
6. Mint new JWT with same claims but fresh iat/exp
7. Return: { playbackToken, tokenExpiresIn }
warning

The refresh endpoint extracts the access code from the JWT sub claim. The raw access code is never stored in browser memory after the initial validation — only the JWT is kept.

Session Management

Heartbeat

The player sends heartbeats every 30 seconds to prove the viewer is still active:

// use-session-heartbeat.ts hook
POST /api/playback/heartbeat
Authorization: Bearer <JWT>

→ Updates ActiveSession.lastHeartbeat to now()
→ Response: { ok: true }

Session Timeout

If a session's lastHeartbeat is older than SESSION_TIMEOUT_SECONDS (default: 60s), the session is considered abandoned. This allows another device to use the same token.

Session Release

On player close, the browser sends a navigator.sendBeacon() request:

// use-session-release.ts hook
POST /api/playback/release
Authorization: Bearer <JWT>

→ Deletes ActiveSession record
→ Response: { released: true }
tip

sendBeacon() is used because it reliably fires during beforeunload / visibilitychange events, unlike fetch() which may be cancelled by the browser.

Rate Limiting

Three in-memory sliding-window rate limiters protect critical endpoints:

LimiterLimitWindowKeyEndpoint
Token validation5 requests1 minuteClient IPPOST /api/tokens/validate
JWT refresh12 requests1 hourToken codePOST /api/playback/refresh
Admin login10 requests1 minuteClient IPPOST /api/admin/login

The RateLimiter class uses an in-memory Map with a sliding window algorithm. It returns { allowed: boolean; retryAfterMs?: number } and expired entries are periodically cleaned up.

Stream Probing

The Platform App determines event status by probing the HLS server:

// lib/stream-probe.ts
async function probeStreamLive(eventId: string): Promise<boolean> {
// 1. Mint a short-lived probe JWT (10s expiry, probe: true)
// 2. HEAD request to HLS server's manifest URL
// 3. Check if response is 200 and manifest was recently modified (< 60s)
}

The probe JWT has probe: true in its claims, which restricts it to HEAD requests only on the HLS server side.

React Component Organization

UI Components (components/ui/)

9 shadcn/ui primitives: Badge, Button, Card, Dialog, Input, Label, Select, Toast, Toaster

Player Components (components/player/)

ComponentPurpose
video-playerMain HLS player container with hls.js integration
fullscreen-toggleFullscreen enter/exit button
live-badge"LIVE" indicator badge
loading-overlayBuffering/loading spinner overlay
play-pause-buttonPlay/pause toggle
progress-barSeek bar with buffer visualization
quality-selectorHLS quality level selector
time-displayCurrent time / duration display
volume-controlVolume slider with mute toggle

Admin Components (components/admin/)

ComponentPurpose
admin-sidebarNavigation sidebar
event-formCreate/edit event form
event-listEvent listing with actions
event-status-badgeStatus indicator (active/archived/inactive)
login-formAdmin password login form
token-status-badgeToken status indicator (unused/redeemed/expired/revoked)

Viewer Components (components/viewer/)

ComponentPurpose
token-entryAccess code input form
player-screenVideo player + controls wrapper
pre-event-screen"Event hasn't started" waiting screen
access-ended"Your access has ended" screen
error-messageError display (revoked, in-use, etc.)

Custom Hooks

HookPurpose
use-event-statusPolls GET /api/events/:id/status every 30s for pre-event screens
use-expiry-countdownCountdown timer for token expiry with 15-minute warning toast
use-jwt-refreshRefreshes JWT every 50 minutes via POST /api/playback/refresh
use-session-heartbeatSends heartbeat every 30s via POST /api/playback/heartbeat
use-session-releaseRegisters beforeunload/visibilitychange to release session
use-toastToast notification system