Courses sell individual videos. Each video is bought once through Polar and plays through Cloudflare Stream signed URLs. Members on a qualifying plan (VIP by default) can watch everything.
| Decision | Why | Owner |
|---|---|---|
| One one-time Polar product per video, created when the video is published | Per-video purchase without a cart. Polar is merchant of record. | lib/courses/course-polar-product.ts |
| The price is locked once the Polar product exists | Avoids a mismatch between the D1 price and Polar | lib/courses/course-video-service.ts |
| VIP unlocks every video, Premium does not | A product decision, changeable in one place | COURSE_ACCESS_PLANS in lib/courses/course-access-policy.ts, overridable per environment with the COURSES_ACCESS_PLANS var in wrangler.jsonc. none disables plan access. An invalid value falls back to the constant. |
trialing subscriptions do not grant access |
Paid content requires a paid period | ACTIVE_SUBSCRIPTION_STATUSES (same file) |
| Archived videos stay playable for existing buyers, but the detail page returns 404 | Buyers keep what they paid for. New buyers cannot find it. | Token route, detail page |
| A full refund revokes access. A partial refund keeps it. A repurchase after a refund restores it. | Matches Polar's event semantics (order.refunded is also sent for partial refunds) |
lib/courses/course-webhook-entitlements.ts |
| Signed Stream URLs (RS256, short-lived), no DRM | Stops link sharing at reasonable cost. Hard DRM is a non-goal. | lib/courses/stream-signed-url.ts |
lib/courses/course-video-access.ts decides who may watch. A viewer qualifies with any one of the following (the file's doc comment is authoritative):
- an active
video:<id>entitlement (granted by the Polar webhook on a paid order); - a paid or partially refunded order for the video's Polar product placed with the viewer's email;
- an
activesubscription on a plan in the access policy whosecurrent_period_endis still in the future. A VIP who cancels keeps access until the paid period ends; a subscription without a period end does not qualify.
Commerce rows are keyed by email. That is safe because every sign-in method proves the email (email OTP or OAuth, with password sign-in disabled).
The token endpoint also refuses (409 stream_not_signed_only) any video that the service has not confirmed as signed-only on Stream. It documents its status codes in its header comment: app/api/courses/videos/[slug]/token/route.ts.
These live under app/api/courses/. They are not part of /api/v1 or the OpenAPI document.
| Endpoint | Who |
|---|---|
GET /api/courses/videos, GET /api/courses/videos/<slug> |
public catalogue. The Stream uid is never exposed. |
POST /api/courses/videos, PATCH /api/courses/videos/<slug> |
admin |
POST /api/courses/videos/<slug>/publish |
admin. Creates the Polar product. |
POST /api/courses/videos/<slug>/checkout |
signed-in user. Returns a Polar checkout URL. |
POST /api/courses/videos/<slug>/token |
signed-in user with access. Returns a signed playback URL. |
Admin means role === "admin" through resolveCaller, which accepts a session or an API key (lib/courses/course-route-helpers.ts). Request bodies are defined by lib/courses/course-video-schemas.ts.
Pages: /courses and /courses/<slug> (and /vi/...). UI components are in components/courses/.
Stream is not reachable with the current API token or OAuth scopes. The account owner does this once:
- Enable Stream on the hi@ dashboard. Note the customer subdomain (
customer-<code>.cloudflarestream.com). - Create a signing key with a token that has Stream: Edit:
curl -X POST -H "Authorization: Bearer <token>" https://api.cloudflare.com/client/v4/accounts/7ac87c90789159ae1b10b7d78f5dcc08/stream/keysresult.idandresult.jwkare shown once. Pipe them straight into the secrets in the next step. Do not paste them into files or chat. - Set the Worker secrets for each environment. Production uses
--env="", staging uses--env staging(see deployment-guide.md for the wrangler invocation):CF_STREAM_SIGNING_KEY_ID,CF_STREAM_SIGNING_KEY_JWK,CF_STREAM_CUSTOMER_SUBDOMAIN, andCF_STREAM_API_TOKEN(Stream: Edit;CF_ACCOUNT_IDis already a var inwrangler.jsonc). The token endpoint returns 503 until the signing values are set; registering or publishing a video returns 503 until the API token is set. POLAR_ACCESS_TOKENneedsproducts:writeso that publishing can create products.
- Upload the video to Stream. Registering or publishing it calls the Stream API to set
requireSignedURLs: trueand checks the answer (lib/courses/stream-video-api.ts); only then is the video markedstream_signed_onlyin D1. An unknown uid returns 422, a Stream failure 502. - As an admin,
POST /api/courses/videoswith the slug, title, price andstreamUid. The video is created as a draft. POST /api/courses/videos/<slug>/publish. This creates the Polar product and makes the video visible.- Verify on staging:
- an unpaid account gets 403 from the token endpoint;
- a purchaser or VIP gets a URL that plays;
- an anonymous request gets 401.
tests/courses-*.test.ts cover the access policy, admin routes, the Polar product, signed URLs, the token route and webhook entitlements.