SkillPixel LMS: Gated lesson media on R2

Gate tenant lesson media behind a Cloudflare Worker with signed cookies, reading from a private R2 bucket. Jira master SP-669

Status PLANNED · 2026-09-03 · Khang Nguyen · Roadmap · Domain provisioning plan

Move quiz media to a private bucket and gate reads at the edge.

Legend FACTtrue today, verified DECIDEDalready agreed PLANNEDin this plan, not built DECISION NEEDEDneeds a call REJECTEDconsidered, not doing
Summary

Signed cookie gate for lesson media PLANNED

Today course content sits in a public R2 bucket behind static.skillpixel.vn. Keys are random, so brute force is not a risk, but any leaked URL works forever. We put a Cloudflare Worker at /media/* on every tenant host, move lesson content to a private bucket, and only serve requests that carry a signed cookie. Marketing assets stay public on purpose.

EstimateValue
Engineer days8 to 11
Calendar for one developer2 to 3 weeks

1What is in the public bucket today FACT

Prod inventory, 3 tenants, queried 2026-09-03.

ContentCountPublic R2 today?Plan
Questions with images4,225Yes, all under tenants/ keys, served from static.skillpixel.vnGate
Questions with audio121YesGate
Questions with inline base64 images671No, stored in the DBOut of scope
Lesson attachments, resources JSON89No, all external links, zero uploaded filesGate new uploads when a file upload flow exists
Reading lessons with images3NegligibleGate new uploads
Course covers17, 10 tenant keys plus 7 legacy keysYesStay public
Slide lessons0Already in the private slide bucket with 5-minute presigned GET URLsUnchanged
Video lessons36Bunny CDN, not R2Check Bunny token authentication is on
Student submissions: assignment, practice, grading artefactsnot countedYesGate in follow-up SP-676
The tenant IP in the public bucket is quiz images and quiz audio. That is the first cut. Student submissions are student data and arguably matter more. They are a follow-up only because the quiz path is smaller and proves the pattern.

2Threat model

Does not fix

Brute force

A private bucket does not change this, and it was never a real risk. Keys end in a 21-character nanoid and the tenant id is a random UUID, over 120 bits of randomness per file. R2 public buckets do not allow listing.

Does fix

Leaked and shared links

  • A student shares a link after leaving.
  • A scraper with one paid account mirrors a tenant.
  • Hot-linking that costs us bandwidth.

Be honest: an enrolled student can always download what they can see. No storage setup changes that. The question is only whether leaked links keep working.

3Why the tenant prefix in keys is fine

The tenant id comes from gen_random_uuid() and the API already returns it on the user profile. The bucket does not list, and the prefix is not an access control. The gate uses the prefix only to match the cookie tenant against the key tenant.

tenant_object_key in apps/backend/app/services/storage.py is the only place a tenant id enters a key. Never put a slug or hostname in a key.

4Architecture

A Worker on the tenant host reads a private bucket. Auth runs before the cache. Uploads do not change.

Pieces

  • Private R2 bucket. Public access off. The Worker reads it through an R2 binding, so no S3 keys leave Cloudflare. Reuse the slide bucket or add a media bucket per env. SP-670 decides.
  • Route. Worker mounted at /media/* on every tenant host. Tenant hosts already go through Cloudflare, both the *.skillpixel.vn wildcard and Phase 4 custom domains via Cloudflare for SaaS. Stored URLs become path-only: /media/tenants/{tenant_id}/quiz-images/{quiz_nanoid}/{file}.png
  • Signed token and cookie. After Firebase login the frontend calls the backend, which checks tenant membership and returns a token {tenant_id, user_id, exp} with an HMAC. The frontend POSTs the token to /media/session on the current host and the Worker sets the sp_media cookie. HttpOnly, Secure, SameSite=Lax, Path=/media, about 30 minutes.
  • Why the Worker sets the cookie, not the API. The API host is not the tenant host. A Set-Cookie from the api host with Domain=.skillpixel.vn reaches wildcard tenants but never a custom domain like learn.partner.com. The cookie must be first-party on the page host.
  • Refresh. Timer at two thirds of cookie life, paused when the tab is hidden, plus a one-shot refresh and retry when an image or audio element errors with 401. This piggy-backs on the Firebase token renewal the frontend already does.
  • Uploads unchanged. Presigned PUT still writes to the bucket. Only reads change.

Login and first image load

sequenceDiagram
    participant B as Browser tenant-a.skillpixel.vn
    participant API as Backend API
    participant W as Worker
    participant R2 as R2 bucket
    B->>API: Firebase login, POST /media/session, bearer
    API->>API: membership active? sign {tenant, user, exp}
    API-->>B: token JSON
    B->>W: POST /media/session {token}
    W-->>B: Set-Cookie sp_media, HttpOnly, Secure, Path=/media, 30 min
    B->>W: GET /media/tenants/A/quiz-images/x/y.png, cookie
    W->>W: verify HMAC, exp, tenant A == key prefix
    W->>W: edge cache lookup
    alt cache miss
        W->>R2: get(key)
        R2-->>W: bytes
        W->>W: store in cache
    end
    W-->>B: 200, Cache-Control private, max-age=300
    

Worker decision per request

flowchart TD
    A["Request /media/tenants/T/..."] --> B{cookie present?}
    B -- no --> X1[401]
    B -- yes --> C{HMAC valid and not expired, 60s slack?}
    C -- no --> X2[401]
    C -- yes --> D{cookie tenant == T?}
    D -- no --> X3[403]
    D -- yes --> E{edge cache hit?}
    E -- yes --> S[serve]
    E -- no --> F[read R2 binding]
    F --> G{exists?}
    G -- no --> X4[404]
    G -- yes --> H[store in cache] --> S
    

Leaked link

sequenceDiagram
    participant S as Student
    participant O as Outsider
    participant W as Worker
    S->>O: shares link
    O->>W: GET /media/... without cookie
    W-->>O: 401
    Note over O,W: no cookie without a tenant login
    

Refresh

sequenceDiagram
    participant F as Frontend
    participant API as Backend
    participant W as Worker
    Note over F: timer fires or Firebase token renewed
    F->>API: POST /media/session
    API->>API: membership check
    API-->>F: token
    F->>W: POST /media/session
    W-->>F: Set-Cookie sp_media
    Note over F: laptop slept, cookie expired
    F->>W: GET image
    W-->>F: 401
    F->>API: refresh once
    API-->>F: token
    F->>W: retry GET image
    W-->>F: 200
    

5Cookie and token spec

FieldValue
Namesp_media
Payloadtenant_id, user_id, exp in unix seconds, optional nonce
SignatureHMAC-SHA256 over the payload with a secret shared between backend, in Infisical, and Worker, as a wrangler secret
FlagsHttpOnly, Secure, SameSite=Lax, Path=/media
Max-Age1800 s
Refreshabout 1200 s
Clock slack60 s on the Worker
RotationNew cookies, handled by the refresh call
The gate is tenant-level, not course-level. Any member of tenant A can fetch any tenant A file if they know the URL. Course-level checks would need the Worker to call the backend or carry enrollment ids. Do not start there.

6Why not presigned URLs everywhere

Presigned GET works for slides because pages are fetched fresh per view. For images inside stored HTML you would rewrite every URL on every read, and browsers cannot cache a URL that changes each time. The cookie gate keeps stored HTML stable and keeps edge and browser caching.

7What stays public

Course covers, tenant branding, banners, blog images, instructor avatars, hall of fame. Landing pages and search engines need them. Nothing moves for these.

8Work plan

Total 8 to 11 engineer days for phases 0 to 5. Worker and frontend can run in parallel with backend.

PhaseTicketWhatEffortDepends on
0SP-670Spike: Worker route on Cloudflare for SaaS custom hostnames, bucket choice, HMAC secret in Infisical and Worker0.5 to 1 daynone
1SP-671Media Worker: cookie parse, HMAC, tenant prefix match, R2 binding, edge cache, Range support, wrangler config, CI deploy, vitest and miniflare tests1.5 to 2 daysSP-670
2SP-672Backend: session token endpoint with membership check; gated write path in the storage service returning /media/ URLs; switch quiz image and audio uploads; update validators in rich_text.py, key parsers, delete helpers, clone path to the private bucket2.5 to 3 daysSP-670
3SP-673Frontend: refresh on Firebase token renewal plus timer; error listener and one-shot retry; logout clears cookie; local dev via wrangler dev behind the Vite proxy or a dev-only backend route1.5 to 2 daysSP-671, SP-672
4SP-674Migration: server-side copy of quiz objects to the private bucket, rewrite URLs in question text, explanation, options, resources; dry run, rollback file, beta then prod; delete public copies after a soak1 to 1.5 daysSP-672
5SP-675Rollout: beta walkthrough on wildcard and custom domain, Safari audio Range check, PostHog watch, prod release, public bucket cleanup1 daySP-673, SP-674
Follow-upSP-676Gate student submissions, assignment, practice, grading artefacts, the same wayabout 2 daysSP-675
RelatedSP-677Hard-copy R2 assets on cross-tenant course copies, licensing and marketplace: course image, lesson description images, file resourcesabout 2 daysSP-672

Dependency graph

flowchart LR
    SP670["SP-670 Spike"] --> SP671["SP-671 Worker"]
    SP670 --> SP672["SP-672 Backend"]
    SP671 --> SP673["SP-673 Frontend"]
    SP672 --> SP673
    SP672 --> SP674["SP-674 Migration"]
    SP673 --> SP675["SP-675 Rollout"]
    SP674 --> SP675
    SP675 --> SP676["SP-676 Submissions"]
    SP672 --> SP677["SP-677 Cross-tenant copy"]
    

9Detailed steps per phase

SP-670 Spike

  1. Confirm a Worker route on /media/* fires on Cloudflare for SaaS custom hostnames, not only on wildcard hosts.
  2. Decide the bucket: reuse the slide bucket or add a media bucket per env.
  3. Generate the HMAC secret and place it in Infisical and as a wrangler secret.
  4. Write the route and secret decisions back into this plan.

SP-671 Media Worker

  1. Parse the sp_media cookie and verify the HMAC with 60 s slack on exp.
  2. Match the cookie tenant against the key tenant prefix. Return 401 on no or bad cookie, 403 on tenant mismatch.
  3. Read the object through the R2 binding. Return 404 when it is missing.
  4. Add edge cache lookup and store, and support Range requests for audio.
  5. Handle POST /media/session: verify the token and set the cookie.
  6. Write wrangler config and a CI deploy job.
  7. Cover it with vitest and miniflare tests.

SP-672 Backend

  1. Add the session token endpoint with a membership check that signs {tenant_id, user_id, exp}.
  2. Update apps/backend/app/services/storage.py: tenant_object_key, public_blob_url, quiz_image_key_from_url, quiz_audio_key_from_url, and the copy_objects container, so the gated write path returns /media/ URLs.
  3. Switch quiz image and audio uploads to the private bucket.
  4. Update services/question.py: the base64 embed rewrite around lines 945 to 997 and clone_questions_for_duplicate_quiz.
  5. Update services/rich_text.py validators and extractors, plus the key parsers and delete helpers.
  6. Update the clone paths: services/b2b_licensing.py _materialise_copy, services/tenant_marketplace.py _create_managed_replica, and services/course.py duplicate_course.
  7. 37 backend test files reference the storage service. Only the quiz-related ones need edits.

SP-673 Frontend

  1. Refresh the cookie on Firebase token renewal plus a timer at two thirds of cookie life, paused when the tab is hidden.
  2. Reuse apps/frontend/src/lib/scheduleUrlRefresh.ts, the same timer helper used for slide presigned URLs.
  3. Add an error listener on image and audio elements: one-shot refresh and retry on a 401.
  4. Clear the cookie on logout.
  5. Wire local dev: wrangler dev behind the Vite proxy or a dev-only backend route. The API base is VITE_API_BASE_URL, and requests send X-Tenant-Host, so the backend resolves the tenant from that header.

SP-674 Migration

  1. Server-side copy of quiz objects from https://static.skillpixel.vn to the private bucket under target path prefix /media/.
  2. Rewrite URLs in question text, explanation, options, and resources. Use the inventory counts from section 1.
  3. Keep the 671 base64 questions untouched.
  4. Dry run first, write a rollback file, then run beta, then prod.
  5. Delete the public copies after a soak.

SP-675 Rollout

  1. Beta walkthrough on a wildcard host and a custom domain.
  2. Check Safari audio with Range requests on a real device.
  3. Watch PostHog for image and audio errors.
  4. Release to prod, then clean up the public bucket.

10Risks and open questions

RiskDetail
Cloudflare for SaaS routesIf the Worker route does not fire on SaaS custom hostnames, the fallback is a media subdomain per custom domain, which adds DNS work per tenant and about 2 days. SP-670 answers this on day one.
Bunny videosThe backend only signs uploads. Confirm token authentication is enabled on the Bunny library, otherwise video links are as shareable as images today.
Cache and revocationA removed member keeps access until the cookie expires, at most 30 minutes. Acceptable.
Range and Safari audioTest on real Safari.
Local devNeeds a decision: wrangler dev or a dev-only backend route. DECISION NEEDED

11Decisions locked DECIDED

  • Tenant-level gate.
  • First-party cookie on the tenant host.
  • Worker sets the cookie.
  • Auth before cache.
  • Path-only stored URLs.
  • Uploads unchanged.
  • Marketing assets stay public.
  • Base64 questions untouched.