Imported from MarineTeam/fable-video (
.claude/skills/domain-reference/SKILL.md). Install upstream withnpx skills add MarineTeam/fable-video --skill domain-reference. Copyright stays with the author.
name: domain-reference description: Third-party domain knowledge for the Marine Video Portal — how Auth0 v4, bunny.net Stream, Upstash Redis, Resend, TUS uploads, and player.js actually behave IN THIS REPO (not generic docs). Load when reading or writing code that touches login/session, video upload/playback/tokens, collections, Redis keys, share-link email, or resume progress, and you need to know what a service call does, what a status code means, what a key holds, or why a signature formula is shaped the way it is. Trigger phrases: "what is X", "how does Y work here", embed token, TUS, pull zone, CDN hostname, collection, GUID, rolling session, sliding window, TTL, Resend, player.js.
Domain reference — Marine Video Portal
This file is the third-party knowledge a mid-level engineer would have to relearn from scratch: what Auth0 v4, bunny.net Stream, Upstash Redis, Resend, TUS, and player.js actually do in this codebase, not in general. It is not a tutorial on any of these products — it only documents the slice this app uses and exactly where.
Verified against the repository at v1.6.0 (2026-07-07), re-verified 2026-07-10; §8 (Web Push + PWA) added and re-verified 2026-07-15 against v1.8.0. Everything below was read directly from the cited files, not inferred. Line numbers are not cited because they drift; function/file names are cited instead — grep for them if you need the exact line.
When NOT to use this skill
| You are trying to... | Use instead |
|---|---|
| Decide whether a change is allowed, which gates to run, PR flow | change-control |
| Understand system-wide invariants (why the architecture is shaped this way) | architecture-contract |
| Respond to a CodeQL alert or suspected vulnerability (e.g. "why is SHA256 signing flagged as password hashing") | security-response |
| Bump a dependency, fix an install/peer-dependency failure | dependency-currency |
| Debug a runtime failure (500s, login loops, blank pages) | debugging-playbook |
Understand a past incident or why a commit exists (e.g. the pvp: → fablevideo: key rename) |
failure-archaeology |
| Add/change environment variables or config files | environment-and-config |
| Deploy, redeploy, or operate the running app | run-and-operate |
| Write or extend tests | validation-and-qa |
| Set up local tooling or diagnostics | diagnostics-and-tooling |
| Write README/CHANGELOG/docs prose | docs-and-writing |
| Plan and ship a whole feature end to end | feature-shipping-campaign |
Use this skill only for "how does the third-party service actually behave here" questions — signature formulas, status codes, key shapes, route maps, payload shapes.
1. Glossary
Definitions as they apply in this app. If a term has a generic meaning elsewhere, this is the meaning that matters here.
| Term | Meaning here |
|---|---|
| Embed token | A short-lived hex string bunny.net requires as a query param (?token=...&expires=...) to play a video through iframe.mediadelivery.net/embed/.... Generated fresh per page load by signEmbedUrl() in lib/bunny.js. Never stored — recomputed on every getServerSideProps call. |
| Pull zone | bunny.net's CDN layer that serves files (thumbnails here) from a hostname like vz-xxxx-xxx.b-cdn.net. Configured via BUNNY_CDN_HOSTNAME. The app never uses a pull zone for video playback (only the tokenized embed iframe), only for thumbnail images. |
| CDN hostname | The pull zone's public hostname (BUNNY_CDN_HOSTNAME). Thumbnails are built as https://{cdnHostname}/{guid}/{thumbnailFileName}. Without it set, thumbnailsEnabled() returns false and the homepage falls back to a plain title list. |
| Collection | A bunny.net Stream grouping of videos (their own object with an id, not a Redis concept). Created/listed/deleted via /collections endpoints in lib/bunny.js; a video's collectionId field is set via updateVideo. Used for the homepage collection-filter chips. |
| GUID | bunny.net's video ID field, literally named guid in every API response. The app calls it video.guid internally but exposes it to viewers as videoId / id. This is the primary key used in Redis progress keys, order arrays, and share records. |
| TUS | An open resumable-upload HTTP protocol. Here it means: the admin's browser uploads a video file directly to https://video.bunnycdn.com/tusupload via the tus-js-client npm package, bypassing this app's own server entirely (no file bytes ever pass through Vercel). |
| Rolling session | An Auth0 session whose expiry is pushed forward on every request that hits the SDK's middleware, instead of expiring at a fixed time after login. Requires proxy.js's matcher to run on (almost) every route — see section 2. |
| Sliding window | The rate-limiting algorithm from @upstash/ratelimit (Ratelimit.slidingWindow(tokens, window)) used for every limiter in this app — see section 7. Distinct from a fixed window: it smooths out the "burst right at the window boundary" problem. |
| TTL | Time-to-live, i.e. Redis key expiry in seconds, set via the ex option on redis().set(key, val, { ex: seconds }). Used for share links (lib/shares.js) and rate-limit counters. Most other keys (settings, viewers, order, progress, theme, audit) have no TTL — they persist until explicitly overwritten or deleted. |
| Serverless instance | A Vercel function invocation. lib/bunny.js's listAllVideos() cache (VIDEO_LIST_CACHE_TTL_MS = 4000) is an in-memory module-level variable, so it only helps within one warm instance — a cold start or a different concurrent instance gets no benefit from it. |
SSR / getServerSideProps |
Pages Router's per-request server render. Used on pages/index.js, pages/admin.js, pages/watch/video/[id].js, pages/watch/[shareId].js — all four do the Auth0 session check and access-control redirect server-side, before any HTML reaches the browser. |
| Hydration | React attaching event handlers to server-rendered HTML in the browser. Relevant because pages/index.js deliberately fetches the video library in getServerSideProps (comment: "otherwise the client waits for hydration, then a whole extra fetch/bunny.net round trip") rather than fetching client-side after mount. |
| Web Push | The browser standard for delivering server-initiated notifications to a subscribed browser even when the site isn't open. Here: lib/push.js + the web-push npm package send them, public/sw.js's push/notificationclick handlers display and route them, components/PushToggle.js subscribes/unsubscribes. Inert unless VAPID keys are set (see section 8). |
| VAPID | "Voluntary Application Server Identification" — the keypair that authenticates this server to the browser's push service. The public key (NEXT_PUBLIC_VAPID_PUBLIC_KEY) is the applicationServerKey the browser subscribes with and is not secret; the private key (VAPID_PRIVATE_KEY) signs each send and is a secret. Generated together with npx web-push generate-vapid-keys. |
| Service worker | The background script public/sw.js, registered by pages/_app.js. It makes the app an installable PWA, caches a fixed allowlist of static icons for offline load, and hosts the Web Push push/notificationclick handlers. It deliberately never caches Auth0, /api/*, or signed video/thumbnail responses (see architecture-contract invariant (k)). |
| PWA / installable | Progressive Web App: with a linked web manifest (public/manifest.webmanifest, linked in pages/_document.js) plus a registered service worker, the browser offers to install the portal to the home screen and launch it standalone. Note: iOS/iPadOS only delivers Web Push to the installed PWA (16.4+), not to Safari tabs — see section 8. |
2. Auth0 v4 as used here
The client object
lib/auth0.js is the entire Auth0 wiring:
import { Auth0Client } from "@auth0/nextjs-auth0/server";
export const auth0 = new Auth0Client();
Zero-config constructor — it reads AUTH0_DOMAIN, AUTH0_CLIENT_ID,
AUTH0_CLIENT_SECRET, AUTH0_SECRET, and APP_BASE_URL from
process.env implicitly. There is no options object anywhere in this repo;
if you need to pass an explicit option (e.g. a custom session duration),
you're changing this constructor call, which makes the change
security-touching per change-control.
Route map
Auth routes are mounted, not defined as page files — they don't exist
under pages/api/auth/* or pages/auth/*. proxy.js calls
auth0.middleware(request), and the SDK internally handles these paths:
| Route | Purpose |
|---|---|
/auth/login |
Starts the Auth0 Universal Login redirect. Called with a ?returnTo= query param everywhere the app redirects an unauthenticated visitor (see pages/index.js, pages/watch/[shareId].js, pages/watch/video/[id].js: `/auth/login?returnTo=${encodeURIComponent(resolvedUrl)}`). |
/auth/logout |
Ends the session and redirects to APP_BASE_URL. Linked directly as <a href="/auth/logout"> in pages/watch/[shareId].js and elsewhere (no page component needed — it's a real route the SDK serves). |
/auth/callback |
Auth0's OAuth callback target. Must match the Allowed Callback URLs configured in the Auth0 dashboard application settings (https://your-domain/auth/callback, per README's one-time setup checklist). |
/auth/profile |
Returns the current session's profile as JSON. Not directly called by any page in this repo (the app reads the session server-side via getSession instead), but it's live because the SDK mounts it unconditionally. |
v3 → v4 migration trap table
From README's "Upgrading from an older deployment?" note — relevant any time you see old-SDK code or a stale dashboard config:
| v3 (old) | v4 (this repo) | Consequence of missing it |
|---|---|---|
AUTH0_BASE_URL |
APP_BASE_URL |
Login redirect loop or wrong callback host |
AUTH0_ISSUER_BASE_URL (with https://) |
AUTH0_DOMAIN (bare hostname, no scheme) |
Auth0Client fails to resolve the tenant |
Routes at /api/auth/* |
Routes at /auth/* |
Auth0 dashboard's Allowed Callback URLs must point at /auth/callback, not /api/auth/callback, or login fails at the callback step |
Session reads
Every server-side session check in this app goes through
auth0.getSession(req), never through a client-side hook. Two call sites:
lib/guard.js(sessionEmail()) — used by every/api/**route viarequireUser/requireApproved/requireAdmin.getServerSidePropsdirectly, inpages/index.js,pages/admin.js,pages/watch/video/[id].js,pages/watch/[shareId].js— each one callsauth0.getSession(req), extractssession?.user?.email, and redirects to/auth/login?returnTo=...if absent.
The session object's only identity input the app trusts is
session.user.email. session.user.name is read too (for display, e.g.
session.user.name || email fallback), but every access-control decision —
isAdmin(), isApprovedViewer(), share-link recipient matching — is keyed
purely on the normalized email from lib/auth.js's normalizeEmail()
(trim + lowercase). There is no role claim, no Auth0 "app_metadata" lookup,
nothing else consulted.
Rolling sessions and why proxy.js's matcher must stay broad
proxy.js (Next.js 16's replacement for middleware.js) is three lines of
substance:
export async function proxy(request) {
return auth0.middleware(request);
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
],
};
The file's own comment: "Everything except static assets — the broad
matcher is required for rolling sessions to refresh on ordinary page/API
traffic." Concretely: the Auth0 SDK extends session expiry on every request
that passes through its middleware. If the matcher were narrowed (e.g. to
exclude /api/** or watch pages), sessions would stop refreshing on those
routes and users could be logged out mid-session even while actively using
the app. Narrowing this matcher is a security-touching change per
change-control's classification table.
3. bunny.net Stream as used here
All calls go through lib/bunny.js. Base URL:
https://video.bunnycdn.com
Every call to /library/{libraryId}/... is authenticated with an
AccessKey header set to BUNNY_API_KEY (see the api() helper). There is
no OAuth, no signed request for the management API itself — only for the
three playback/upload artifacts described below. Env values are read via a
local env() helper that trims the value defensively (comment: "a stray
newline in a pasted key corrupts TUS signatures and API calls").
Every endpoint the app calls
| Function | Method + path | Purpose |
|---|---|---|
listVideos({page, itemsPerPage}) |
GET /videos?page=&itemsPerPage=&orderBy=date |
One page of the library, newest first. |
listAllVideos({maxPages}) |
(wraps listVideos, paginated in parallel after page 1) |
Full library, module-cached 4s per warm serverless instance (VIDEO_LIST_CACHE_TTL_MS). |
getVideo(id) |
GET /videos/{id} |
Single video's metadata (status, title, guid, collectionId, thumbnailFileName, length). |
createVideo(title, collectionId) |
POST /videos |
Creates the bunny.net video record before a TUS upload starts. Invalidates the list cache. |
updateVideo(id, patch) |
POST /videos/{id} |
Rename / change collection. Invalidates the list cache. |
deleteVideo(id) |
DELETE /videos/{id} |
Delete, or clean up a cancelled upload's half-created record. Invalidates the list cache. |
listCollections() |
GET /collections?page=1&itemsPerPage=100&orderBy=date |
All collections for the homepage filter and admin collection manager. |
createCollection(name) |
POST /collections |
Admin "create collection". |
deleteCollection(id) |
DELETE /collections/{id} |
Admin "delete collection". |
getStatistics({dateFrom, dateTo}) |
GET /statistics?dateFrom=&dateTo= |
Views / watch-time data for the Analytics tab. |
(no dedicated function — signTusUpload() builds the request the browser then makes) |
POST https://video.bunnycdn.com/tusupload (TUS protocol) |
The actual resumable file upload, made directly by the admin's browser, not by this server. |
Video status codes
videoState() in lib/bunny.js maps bunny.net's numeric status field to
one of three UI states:
| Code | bunny.net meaning | App-mapped state |
|---|---|---|
| 0 | Created | processing |
| 1 | Uploaded | processing |
| 2 | Processing | processing |
| 3 | Transcoding | processing |
| 4 | Finished | ready |
| 5 | Error | failed |
| 6 | Upload failed | failed |
| 7+ | JIT (just-in-time) encoding states | ready (already playable) |
Exact logic: status === 5 || status === 6 → "failed";
status === 4 || status > 6 → "ready"; everything else (0–3) →
"processing". Only "ready" videos are ever shown to viewers
(lib/videoList.js filters on this before building the homepage list).
The three keys, disambiguated
This is the single most common source of confusion in this codebase — three different bunny.net keys, each used for a different signature, with one silent fallback.
| Env var | Used for | Read by |
|---|---|---|
BUNNY_API_KEY |
(a) Authenticating every Stream management API call (the AccessKey header) and (b) part of the TUS upload signature formula |
apiKey() in lib/bunny.js, used in api() and signTusUpload() |
BUNNY_TOKEN_AUTH_KEY |
Signing embed-view tokens (video playback) | tokenAuthKey(), used in signEmbedUrl(); also the fallback for thumbnail signing |
BUNNY_CDN_TOKEN_KEY |
Signing thumbnail URLs (pull-zone "Block Direct URL File Access" token auth) | cdnTokenKey() — `env("BUNNY_CDN_TOKEN_KEY") |
If you rotate BUNNY_TOKEN_AUTH_KEY in the bunny.net dashboard without
also checking whether BUNNY_CDN_TOKEN_KEY was relying on the fallback,
thumbnails silently start 401'ing while playback keeps working (or vice
versa) — the two are independent unless the fallback is in effect.
The three signature formulas (verbatim)
All three are SHA-256 over a concatenated string (no delimiter, no
HMAC — plain digest). bunny.net independently recomputes and verifies each
one server-side; changing the concatenation order, the hash algorithm, or
the encoding here breaks playback/upload/thumbnails without any local error
— the failure only shows up as a bunny.net-side rejection. CodeQL flags
these as "weak password hashing" — that is a false positive (these are
per-request authorization tokens, not credential storage); see
security-response for the accepted-risk record.
| Artifact | Formula | Encoding | TTL |
|---|---|---|---|
Embed token (signEmbedUrl) |
SHA256(BUNNY_TOKEN_AUTH_KEY + videoId + expires) |
hex | 3 hours (ttlSeconds = 3 * 3600) |
TUS upload signature (signTusUpload) |
SHA256(libraryId + BUNNY_API_KEY + expire + videoId) |
hex | 6 hours (ttlSeconds = 6 * 3600) |
Thumbnail token (thumbnailUrl) |
SHA256(cdnTokenKey + path + expires) where path is /{guid}/{thumbnailFileName} |
base64url (base64, then +→-, /→_, strip trailing =) |
6 hours (hardcoded 6 * 3600, not parameterized) |
expires / expire in all three is a Unix timestamp in seconds
(Math.floor(Date.now() / 1000) + ttlSeconds), not milliseconds.
The TUS upload flow, end to end
- Admin drops a file in
pages/admin.js's upload UI.startUpload(file)runs client-side. - It first calls
POST /api/admin/uploadwith{ title }(pages/api/admin/upload.js), which:- checks
requireAdmin, - rate-limits via
allowRequest("upload", admin, 30, "1 h"), - calls
createVideo(title, collectionId)— this is the only step that touches bunny.net's management API; it creates an empty video record and returns itsguid, - calls
logAction(admin, "video.upload", title), - responds
201with{ video: { id, title }, tus: signTusUpload(video.guid) }— i.e.{ endpoint, signature, expire, videoId, libraryId }.
- checks
- Back in the browser,
pages/admin.jsdynamically importstus-js-clientand constructsnew Upload(file, { endpoint: created.tus.endpoint, ... })with headersAuthorizationSignature,AuthorizationExpire,VideoId,LibraryIdtaken verbatim from that response, then callsupload.start(). - From this point, the file bytes go straight from the browser to
https://video.bunnycdn.com/tusupload— this app's server is not in the data path at all, only the auth handshake in step 2. onProgressdrives the progress bar;onSuccessmarks the row"done"and callsload()to refresh the video list;onErrorsurfaces the error and offers retry (retryUpload), which re-POSTs step 2 for a new signature (the old one may have expired) and restarts the sametus-js-clientUpload.- Cancelling before completion calls
upload.abort(true)client-side, thenDELETE /api/admin/upload?id=...server-side, which callsdeleteVideo(id)to remove the half-created bunny.net record andpruneFromOrder(id)to drop it from any saved homepage order.
Embed iframe URL shape
https://iframe.mediadelivery.net/embed/{libraryId}/{videoId}?token={sha256hex}&expires={unixSeconds}&autoplay=false
Built by signEmbedUrl(). Note the host is iframe.mediadelivery.net, a
different domain from the Stream management API (video.bunnycdn.com) and
from the CDN pull zone (BUNNY_CDN_HOSTNAME) — three different bunny.net
hostnames serve three different purposes in this app. This URL is generated
fresh in getServerSideProps on every watch-page load
(pages/watch/video/[id].js, pages/watch/[shareId].js) and passed as the
iframe src to ResumablePlayer / the raw <iframe>. It is never persisted
to Redis or anywhere else.
Direct media (MP4) URLs — and the audio-only question
Verified against bunny.net's documentation on 2026-09-13, not assumed. Stream has no audio-only or MP3 rendition. A video's stored files are:
playlist.m3u8(HLS),- optional
play_{height}p.mp4fallbacks (1080/720/360/240), original(only with "keep original files" on),thumbnail.jpg+ numbered variants,seek/_N.jpgsprites,preview.webp|gif| webm|mp4,captions/{lang}.vtt.
Two conditions on the MP4 fallbacks, both set on the bunny.net LIBRARY and not settable from this repo:
- MP4 Fallback must be enabled under the library's Encoding settings.
- bunny.net generates an MP4 only for videos uploaded after that was turned on — pre-existing videos have none and their URLs 404 until re-uploaded.
Pull-zone token authentication and "Block Direct URL File Access" apply to these URLs
exactly as they do to thumbnails, using the same
base64url(SHA256(cdnTokenKey + path + expires)) formula. That is why the podcast feed
is possible at all: lib/bunnyMedia.js signs /{guid}/play_{height}p.mp4 with the
same scheme thumbnailUrl() uses. Consequence for the feed: episodes are video,
not audio — playable in podcast apps, but a far larger download. See
architecture-contract (s) for why this is a narrowed exception to invariant (d) and
why the formula is duplicated rather than shared with lib/bunny.js.
Thumbnails
- Default filename:
thumbnail.jpg— used whenvideo.thumbnailFileNameis absent (file = video.thumbnailFileName || "thumbnail.jpg"). - Requires
BUNNY_CDN_HOSTNAMEset;thumbnailsEnabled()gates the homepage's grid-vs-list rendering mode entirely on this one var. - Signed with the thumbnail formula above when
cdnTokenKey()resolves to something; if no key is available at all,thumbnailUrl()returns the bare unsignedhttps://{host}{path}(only correct if the pull zone's "Block Direct URL File Access" is off). - README notes the pull zone also does referer-based hotlink protection as an additional layer, independent of the token.
4. Upstash Redis as used here
Client shape
lib/redis.js wraps @upstash/redis's Redis class — a REST-based
client, meaning every command (hget, set, sadd, ...) is one HTTPS
request, not a persistent TCP connection. Practical implications:
- Objects auto-serialize to JSON on write and deserialize on read — a
share record stored as
{ videoId, email, ... }viar.set(key, share)comes back as a real JS object fromr.get(key), no manualJSON.parse/JSON.stringifyneeded (contrast withlib/audit.js, which stores audit entries as explicit JSON strings viaJSON.stringifybeforelpush, and so mustJSON.parsethem back inrecentActions()). hgetallreturns{}for a missing/empty hash, notnull— but the app doesn't rely on that alone; every call site still does(await redis().hgetall(...)) || {}defensively (seegetSettings,listViewers,getProgress).
Key resolution
lib/redis.js's envBySuffix() matches KV_REST_API_URL /
KV_REST_API_TOKEN (or the UPSTASH_REDIS_REST_URL /
UPSTASH_REDIS_REST_TOKEN fallback names) either as an exact env var name
or as a suffix of any env var name (comment: Vercel prefixes storage vars
with the store's name when a project has more than one connected, e.g.
fablevideo_KV_REST_API_URL). If you add a second Redis-backed integration
to this Vercel project, this suffix-matching logic is what keeps the app
finding the right credentials — or silently picking the wrong store's if
names collide.
Complete key inventory
All keys are namespaced through k(...parts) in lib/redis.js, which
joins "fablevideo" with the given parts using :. (Historical note: the
prefix was pvp: before 2026-07-09, commit c37919e; anything under the
old prefix is orphaned data, not read by current code — see
failure-archaeology for the incident.)
| Key | Type | Written by | Read by | TTL |
|---|---|---|---|---|
fablevideo:settings |
hash. Fields include videoCount and siteName (plus the watermark/geo settings) |
saveSettings() / setSiteName() (pages/api/admin/settings.js) |
getSettings() — homepage video count cap; getSiteName() — header, page titles, emails, PWA manifest |
none |
fablevideo:viewers |
hash, field = normalized email, value = {addedAt, addedBy} |
addViewers() (pages/api/admin/viewers.js) |
listViewers(), isApprovedViewer() — access gate |
none |
fablevideo:lastseen |
hash, field = email, value = ISO timestamp | stampLastSeen() (called from requireApproved in lib/guard.js, best-effort) |
listViewers() — admin Viewers tab |
none |
fablevideo:order |
string (JSON array of video GUIDs) | saveOrder() (pages/api/admin/order.js) |
getOrder() → applyOrder() (lib/order.js) — homepage/admin ordering |
none |
fablevideo:theme |
string (JSON object {preset, accent, accent2}) |
saveTheme() (pages/api/theme.js POST) |
getTheme() (pages/api/theme.js GET, pages/_app.js) |
none |
fablevideo:progress:<email> |
hash, field = videoId, value = {t, d, at} |
saveProgress() (pages/api/progress.js POST, called by ResumablePlayer) |
getProgress() (pages/api/progress.js GET — resume position + continue-watching list) |
none |
fablevideo:roles |
hash, field = roleId, value = {id, name, capabilities[], createdAt, updatedAt} |
saveRole() (pages/api/admin/roles.js) |
loadRoles() → resolveCapabilities()/resolveAccess() (lib/roles.js) — the authorization gate |
none. Legacy shape warning: before custom roles this same hash held email -> "manager"|"admin". Leftover rows are inert (parseRole rejects a bare string; isValidRoleId rejects any field with @ or .) and are converted by lib/roleMigration.js |
fablevideo:user:roles |
hash, field = normalized email, value = [roleId, ...] (max 10) |
setRolesForEmail() (pages/api/admin/roles.js PATCH) |
rolesForEmail(), loadRoleAssignments() — which roles a person holds |
none. An empty list deletes the field |
fablevideo:groups |
hash, field = normalized group name, value = {restricted, videoIds} |
pages/api/admin/groups.js; pruned by pruneVideoFromGroups() on video delete |
allowedVideoIds() → resolveAccess().videoScope (lib/groups.js) — per-video allowlist for tagged viewers |
none |
fablevideo:requests |
hash, field = normalized email, value = {name, message, requestedAt, status, decidedAt, decidedBy} |
createAccessRequest() (pages/api/access-request.js), denyAccessRequest() (pages/api/admin/access-requests.js) |
getAccessRequest(), listAccessRequests() (lib/accessRequests.js) — the admin queue. Approval deletes the record; the viewer row becomes the truth |
none |
fablevideo:schedule |
hash, field = video GUID, value = {publishAt, expiresAt} |
setSchedule() (pages/api/admin/videos.js, action: "set-schedule") |
getScheduleMap()/getSchedule() → isLive() (lib/schedule.js) — hides a video from viewers outside its window. Read failures fail OPEN |
none. An empty window deletes the field |
fablevideo:chapters |
hash, field = video GUID, value = JSON array of {t, label} (t = seconds) |
setChapters() (pages/api/admin/videos.js, action: "set-chapters"; parsed server-side by lib/chapters.js) |
getChapters()/getChaptersMap() (lib/videoMeta.js) — the chapter list under the player |
none. An empty list deletes the field |
fablevideo:notes |
hash, field = video GUID, value = plain text (≤ MAX_NOTES_LENGTH) |
setNotes() (pages/api/admin/videos.js, action: "set-notes"; cleaned by lib/notes.js) |
getNotes() (watch page), getNotesMap() (lib/videoList.js, so the client-side search can match notes) |
none. Empty deletes the field |
fablevideo:public |
hash, field = video GUID, value = {enabledAt, enabledBy} |
setPublicVideo() (pages/api/admin/public-videos.js, CAP.SETTINGS) |
isPublicVideo() — the ONLY anonymous video path (pages/watch/public/[id].js). Presence is the whole flag; reads fail CLOSED |
none. Turning it off deletes the field |
fablevideo:feed:tokens |
hash, field = 256-bit base64url token, value = normalized email | rotateFeedToken() (pages/api/feed-token.js) |
emailForToken() → resolveFeedRequest() (lib/feedAccess.js) — identifies the podcast subscriber and nothing more |
none. Rotation deletes the old field; viewer removal deletes both |
fablevideo:feed:byemail |
hash, field = normalized email, value = that account's current token | rotateFeedToken() |
getFeedToken() — so a viewer can be shown their own feed address |
none |
fablevideo:shares |
hash, field = share id, value = JSON share record, per-field TTL (Redis 7.4 hash-field-TTL — HEXPIRE/HSETEX family; confirmed supported by Upstash via @upstash/redis's command bindings) |
createShare(s)/stampShares/revokeShares/unrevokeShares/extendShares — all via writeShares()'s HSETEX (lib/shares.js) |
getShare() (HGET), getShares() (HMGET, batch), listShares() (HGETALL, whole hash in 1 command) |
per-field: ex = hours * 3600 + GRACE_SECONDS at creation/extend, or keepttl (preserved exactly, no read-back) on any patch that doesn't move expiresAt |
fablevideo:audit |
list, capped, JSON-string entries | logAction() (lib/audit.js, called from nearly every admin mutation) |
recentActions() (pages/api/admin/audit.js — Activity tab) |
none; length capped to 200 via ltrim(key, 0, 199) after every lpush |
fablevideo:rl:<name> |
Ratelimit-internal keys (one family per limiter name/tokens/window combo) | @upstash/ratelimit internals via limiterFor() (lib/ratelimit.js) |
same | window-scoped, managed by the @upstash/ratelimit library itself |
fablevideo:push:subs |
hash, field = browser push endpoint, value = { email, sub, addedAt } |
savePushSubscription() (pages/api/push/subscribe.js POST) |
listPushSubscriptions() → sendPushToApproved(); pruned via hdel on dead endpoints and on unsubscribe |
none |
fablevideo:push:notified |
set of video GUIDs already announced | maybeAnnounceReadyVideos() (SADD per newly-ready video, called from pages/api/admin/videos.js) |
same (SMEMBERS to skip already-announced) |
none |
fablevideo:push:seeded |
string sentinel "1" |
maybeAnnounceReadyVideos() on its first run |
same (existence check, so the first run seeds notified without blasting the whole existing library) |
none |
Historical note (pre-v1.13): shares used to live as one STRING key per
share (fablevideo:share:<id>) plus a SET index (fablevideo:shares:index)
for listing. That shape was replaced by the single-hash design above because
Upstash bills a multi-key command (the old MGET over every share key) per
key touched, while a single-hash command (HGETALL/HMGET/HSETEX/HDEL)
bills once regardless of field count — loading the admin Shares tab with
1000 shares dropped from ~1001 commands to 1. scripts/migrate-shares-to-hash.mjs
carries pre-existing data from the old shape into the new one; the old keys
are never read by current code and are left in place afterward, inert —
same resolution as the pvp:* orphans from the 2026-07-09 rename (see
failure-archaeology FA-5). If you're reading a real Redis census and see
both fablevideo:share:* keys and a populated fablevideo:shares hash,
that's expected during/after this transition, not a bug.
Share TTL lifecycle
- Create (
createShare/createSharesinlib/shares.js):ttlHours = clampShareHours(hours)(clamped to 1–720 hours, i.e. up to 30 days; default 72 hours if unspecified/invalid).writeShares()issues oneHSETEX fablevideo:shares EX <ttlHours*3600 + GRACE_SECONDS> FIELDS ...covering every share in the call (1 command for a whole bulk-create batch, since every pair in one call shares the samehours) — Redis drops each field at its own expiry with no app-level cron needed. - Update (
updateShare/stampShares, used to stamp view/playback patches andemailedAt): merges the patch onto the current record (read viagetShare/already in hand from an earlier batch read) and writes back withHSETEX ... KEEPTTL— each field's own existing remaining TTL is preserved exactly by Redis itself, with no separateTTLread-back needed (the old per-key design had toTTLthe key first to avoid resetting it; hash-fieldKEEPTTLmakes that read unnecessary). - List (
listShares): oneHGETALLreturns every non-expired share in the hash — Redis's native per-field TTL means an already-expired share's field is simply absent from the result; no app-level dead-id pruning pass is needed (the old index-set design needed an opportunisticSREMfor this). - Revoke (
revokeShares, soft — the current, in-place-flag behavior;permanentlyDeleteSharesis the old ungraced-delete behavior, used to finish off an already-revoked link): oneHMGET(batch read) + oneHSETEX ... KEEPTTL(batch write of{revoked: true, revokedAt}) for however many ids are selected — flat 2 commands regardless of selection size, instead of a get+ttl+set per id.
5. Resend as used here
lib/email.js talks to Resend's REST API directly — no resend npm SDK
dependency.
| Aspect | Detail |
|---|---|
| Endpoint | POST https://api.resend.com/emails |
| Auth | Authorization: Bearer {RESEND_API_KEY} header |
| Payload | { from, to: [to], subject, html, text }, plus reply_to only if EMAIL_REPLY_TO is set (conditionally added, not sent as empty string) |
| Success | Any res.ok response — sendEmail() returns the parsed JSON body |
| Failure | Non-ok response: tries to parse a JSON body for a .message field to include in the thrown error; falls back to `status ${res.status}` if the body isn't parseable JSON. Always throws — callers (pages/api/admin/share.js, share-email.js) catch this and surface emailError in the admin UI rather than failing the whole request. |
Inert-until-configured contract: emailEnabled() is
Boolean(env("RESEND_API_KEY") && env("EMAIL_FROM")) — both must be set.
Every code path that might send email checks this first (e.g.
pages/api/admin/share.js: if (shouldEmail && emailEnabled())), so with
either var unset, share creation still succeeds and simply returns
emailed: false — there is no error, no attempted call, no partial state.
This is deliberate per README: "Without these, everything still works —
admins copy share links and send them manually."
Domain verification requirement (from README, not in code — nothing in
this repo checks it): the domain in EMAIL_FROM must be verified in
Resend's dashboard, or every send fails at Resend's end with a delivery
error that surfaces through the failure path above into the admin UI. This
is an operational prerequisite, not something lib/email.js can detect or
work around.
The site name in an email is the admin-set one: sendShareEmail() and
sendBulkShareEmail() resolve it from Redis and inject it into the template as
site, falling back to SITE_NAME/NEXT_PUBLIC_SITE_NAME and then
"Marine Video Portal" (lib/siteName.js resolveSiteName()). siteName()
in lib/email.js is now only that env-level fallback, kept synchronous for
callers that can't await. It is
used in the email subject line and body via shareEmailTemplate(), which
also HTML-escapes every interpolated value (escapeHtml()) before building
the HTML body, since videoTitle and the recipient email are admin/user
-influenced strings.
6. player.js / resume
components/ResumablePlayer.js wraps the tokenized bunny.net <iframe>
with the player.js npm package (bunny.net's embed implements the
player.js postMessage protocol on the iframe side; this component is the
protocol client on the host page).
Flow, in order:
- The iframe renders immediately with the signed embed
src— playback works even if the rest of this logic never runs. - On mount,
player.jsis dynamically imported (await import("player.js")) and anew playerjs.Player(iframeRef.current)instance is created, bound to the iframe via postMessage. - On the player's
"ready"event:GET /api/progress?videoId=...fetches any previously saved position; ifsaved.t > 5 && saved.d && saved.t < saved.d * 0.95(i.e. more than 5 seconds in, and not already essentially finished), it callsplayer.setCurrentTime(saved.t)to seek there. - Still inside
"ready", it wires"timeupdate"(updates an in-memorylastKnown = {t, d}on every tick — no network call per tick),"pause"(saves immediately), and"ended"(setst = dand saves — marks the video as finished for continue-watching filtering). - A
setInterval(save, 10000)also persists every 10 seconds while playing, so a browser crash or tab close doesn't lose more than ~10s of progress. save()itself is a guardedPOST /api/progresswith{videoId, t: lastKnown.t, d: lastKnown.d}— it no-ops ifdis falsy ort < 5(nothing meaningful to persist yet), and useskeepalive: trueso the request can complete even during page unload.- Cleanup (component unmount /
videoIdchange): clears the interval and fires one lastsave().
Graceful degradation: the whole player.js setup is wrapped in a
try/catch with the comment "player.js failed to load — plain embed playback
still works." If the dynamic import fails, or the protocol handshake never
completes, the raw <iframe> still plays the video; only resume/progress
tracking is lost, silently, with no user-visible error.
Where progress lands: POST /api/progress
(pages/api/progress.js) validates the payload (videoId string ≤100
chars, t/d finite numbers, t >= 0, d > 0) then calls
saveProgress(email, videoId, {t, d, at}) in lib/store.js, which
hsets into fablevideo:progress:<email> — see the key inventory in
section 4. The same endpoint's plain GET (no videoId) powers the
homepage's "Continue watching" strip: it reads all progress entries,
filters to t > 10 && d > 0 && t < d * 0.95 (started, has a real duration,
not basically finished), sorts by most recent, caps at 8
(MAX_CONTINUE_ITEMS), and enriches each with title/thumbnail via
listAllVideos().
7. Rate limits as configured
Every limiter goes through allowRequest(name, id, tokens, window) in
lib/ratelimit.js, which lazily builds (and caches by
`${name}:${tokens}:${window}`) an @upstash/ratelimit instance using
Ratelimit.slidingWindow(tokens, window), prefixed under
fablevideo:rl:{name}. Fails open on any Redis error — the whole
function is wrapped in try/catch returning true on failure, with the
comment "an infrastructure hiccup must never lock real users out." This
means rate limiting is best-effort, not a hard security boundary — do not
rely on it alone to bound abuse of an endpoint whose real risk is data
exposure (auth/authorization guards are the hard boundary there).
| Call site | name |
Identity key | Tokens / window | User-visible message on 429 |
|---|---|---|---|---|
pages/api/admin/upload.js (POST) |
"upload" |
admin's normalized email | 30 / 1 hour | "Too many uploads started — try again shortly" |
pages/api/admin/share.js (POST) |
"share" |
admin's normalized email | 30 / 1 hour | "Too many share links created — try again shortly" |
pages/api/videos.js (GET) |
"videos" |
viewer's normalized email | 60 / 1 minute | "Too many requests — slow down a little" |
All three key on email, not IP — meaning the limit is per-account, and
an admin/viewer with multiple tabs/devices shares one bucket. There is no
global/unauthenticated-endpoint limiter in this table because every rate-
limited route sits behind requireAdmin/requireApproved first, so the
identity used as the limiter key is always established before the limit
check runs.
8. Web Push and the PWA as used here
Added in v1.7.0 (installable PWA) and v1.8.0 (Web Push notifications). All of it is
inert until VAPID keys are configured — pushEnabled() in lib/push.js is
Boolean(NEXT_PUBLIC_VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY), and every server entry point
checks it first. See environment-and-config for the three env vars; this section is how
the pieces actually behave.
The PWA shell (v1.7.0)
- Manifest:
public/manifest.webmanifest—name"Marine Video Portal",short_name"Marine",display: standalone,start_url/scope/, theme/background#0f172a, and three icons (192, 512, and a 512maskable). Linked frompages/_document.jsvia<link rel="manifest" href="/manifest.webmanifest" />. - Service worker:
public/sw.js, registered client-side inpages/_app.js(navigator.serviceWorker.register("/sw.js"), wrapped so a failure is silently ignored). Oninstallit precaches the manifest + icons (CACHE = "mvp-static-v1") and callsskipWaiting(); onactivateit deletes stale caches and callsclients.claim(). - What makes it installable: a linked manifest with the required icons + a registered
service worker, served over HTTPS. Chrome then offers "Install app"; iOS/iPadOS offers
"Add to Home Screen". Whether the browser shows a direct install entry vs. a generic
"add to home screen / create shortcut" picker is a per-origin, per-device browser decision
(Chrome's
AppBannerManagerengagement heuristic) — not controllable from the manifest, and not a bug in this app.
Subscribe / unsubscribe flow (client)
components/PushToggle.js renders the "🔔 Notify me" button (becomes "🔔 Notifications on"
once subscribed). It:
- No-ops entirely if
NEXT_PUBLIC_VAPID_PUBLIC_KEYis absent, the browser lacksserviceWorker/PushManager/Notification, or notifications are alreadydenied(then it shows a "blocked in browser settings" chip). - On enable:
Notification.requestPermission(), thenregistration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY) }), thenPOST /api/push/subscribewith{ subscription }. - On disable:
getSubscription(),DELETE /api/push/subscribewith{ endpoint }, thensubscription.unsubscribe()locally.
Server: lib/push.js (the whole feature)
| Function | What it does |
|---|---|
pushEnabled() |
true only when both VAPID vars are set. The gate for every path below. |
ensureVapid() |
Lazily calls webpush.setVapidDetails(subject, public, private) once. subject = VAPID_SUBJECT → else APP_BASE_URL → else https://example.com. |
savePushSubscription(email, sub) |
HSET fablevideo:push:subs keyed by the subscription's endpoint, value { email, sub, addedAt }. One field per browser/device. |
removePushSubscription(endpoint, email) |
HDEL the endpoint — but only if it belongs to email (ownership check, so one viewer can't unsubscribe another). |
listPushSubscriptions() |
HGETALL → [{ endpoint, email, sub }]. |
sendPushToApproved(payload) |
Loads subs + the live approved-viewer list in parallel, keeps only subs whose email is a current viewer or admin, webpush.sendNotification to each, and prunes any endpoint that returns 404/410 (subscription gone). Returns { sent, pruned, configured }. |
maybeAnnounceReadyVideos(videos) |
Fire-once "new video" announcer — see below. |
The send-payload shape is a small JSON blob { title, body, url }; public/sw.js's
push handler reads it (event.data.json()), shows registration.showNotification(title, { body, icon: "/icon-192.png", badge: "/icon-192.png", data: { url } }), and
notificationclick focuses an existing tab (navigating it to url) or opens a new window.
The two send triggers
- Automatic, on a new video becoming ready.
pages/api/admin/videos.js(the admin video-list route) callsmaybeAnnounceReadyVideos(videos)best-effort — wrapped in try/catch so a push failure never breaks the admin video list. Logic: filter tostatus === "ready"videos with an id; on the first ever run,SADDthem all tofablevideo:push:notifiedand set thefablevideo:push:seededsentinel without sending (so the pre-existing library isn't blasted); on later runs, for each ready video not already innotified,SADDit and send only ifSADDreturned 1 (atomic — exactly one concurrent instance wins per video). Message:{ title: "New video", body: video.title, url: "/watch/video/<id>" }. - Manual admin broadcast.
POST /api/admin/notify(requireAdmin, rate-limited 10/hour per admin viaallowRequest("notify", ...), audit-logged as"push.broadcast"). Validatestitle(1–100 chars) andbody(≤300 chars), clamps the clickurlto a same-origin path (startsWith("/")else/), callssendPushToApproved, returns{ ok, sent, pruned }. Triggered from the admin Settings tab's broadcast card (pages/admin.js), which shows a setup hint namingNEXT_PUBLIC_VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEYwhen push is unconfigured.
The two subscribe/notify routes, status codes
| Route | Guard | Notable responses |
|---|---|---|
POST/DELETE /api/push/subscribe |
requireApproved (admins count as approved) |
503 if !pushEnabled(); 400 if no subscription.endpoint (POST) / no endpoint (DELETE); 201 on subscribe; 405 otherwise |
POST /api/admin/notify |
requireAdmin |
503 if !pushEnabled(); 429 if over 10/hour; 400 on bad title/body length; 502 on send failure |
iOS caveat (operational, from README, not enforceable in code)
iOS/iPadOS deliver Web Push only to a PWA that's been installed to the Home Screen
(Safari 16.4+), never to an ordinary Safari tab. So on iOS the "Notify me" button only does
anything after the user installs the app — this is an Apple platform constraint, not a bug
in PushToggle.js. Android Chrome and desktop Chrome/Edge/Firefox subscribe from a normal
tab without installing.
Provenance and maintenance
- Verified by reading, on 2026-07-13:
lib/auth0.js,lib/auth.js,lib/guard.js,lib/redis.js,lib/store.js,lib/shares.js,lib/email.js,lib/ratelimit.js,lib/audit.js,lib/order.js,lib/theme.js,lib/theme-client.js,lib/bunny.js,lib/videoList.js,proxy.js,README.md,pages/api/admin/upload.js,pages/api/progress.js,pages/api/videos.js,pages/api/admin/share.js,pages/index.js,pages/admin.js(upload section),pages/watch/video/[id].js,pages/watch/[shareId].js,components/ResumablePlayer.js,package.json. - Repo state at time of writing: v1.6.0 (released 2026-07-07), Redis key
prefix
fablevideo:(since commitc37919e, 2026-07-09). - Updated 2026-07-15 (v1.8.0): added section 8 (Web Push + PWA), four
glossary rows (Web Push, VAPID, service worker, PWA), and the three
the public/feed-token rows and the "Direct media (MP4) URLs" section on
2026-09-13 (the bunny.net audio-only question answered against their live
docs), and the roles/groups/requests/schedule/chapters/notes rows to the
section-4 inventory the same day (the table had drifted — several of those keys shipped in
earlier changes without being recorded here), alongside the earlier
fablevideo:push:*keys — verified by readinglib/push.js,public/sw.js,public/manifest.webmanifest,components/PushToggle.js,pages/_app.js,pages/_document.js,pages/api/push/subscribe.js, andpages/api/admin/notify.json that date. The iOS-install-required-for-push caveat is from README, not exercised here. - Volatile facts to re-check if this file feels stale: the three
signature TTLs (3h/6h/6h) and formulas in
lib/bunny.js; the rate-limit table in section 7 (tokens/window are trivial to change and easy to drift from this doc); the Redis key inventory in section 4 (new features add keys); the video status code table in section 3 (bunny.net could add states). Re-grep the cited functions rather than trusting numbers here if more than a few weeks have passed. - Not independently verified (asserted by README, not exercised
against live services): Resend's actual error-message JSON shape on
failure; the exact wording bunny.net returns on a rejected signature;
whether Auth0's
/auth/profileroute response shape matches any assumption (no code in this repo actually calls it, so there is nothing to cross-check against). - If any of
lib/auth0.js,lib/bunny.js's signature functions,lib/redis.js's key prefix, orlib/email.js's endpoint change, update this file in the same PR — it will otherwise actively mislead the next reader.