Skip to content

feat: courselit plans, trials and limits - #881

Open
clbotdev wants to merge 30 commits into
platform-based-rewritefrom
feat/plan-limits
Open

clbotdev wants to merge 30 commits into
platform-based-rewritefrom
feat/plan-limits

Conversation

@clbotdev

@clbotdev clbotdev commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

Implements docs/wip/plan-limits.md: Pro and Business plans on courselit.app, a cardless 14-day trial with Business features, plan limits, abuse controls, and the security requirements from the reviews (S1–S31). Every milestone was browser-tested on the local stack.

What's in it

Billing and plan state

  • Platform billing moves from Dodo to Lemon Squeezy, using @codelitdev/billing 0.12.0 (planLimits, paid-through entitlement end, store-checked variants).
  • School states (pending_checkout, imported, read_only, suspended) are enforced by a fail-closed allowlist at the dispatch edge.
  • schoolPlan() checks a paid plan first, then a running trial, and returns unknown when billing can't be read.

Trial and lifecycle

  • The account's first school starts a 14-day trial. Later schools wait for checkout, and their site stays offline until then.
  • When a trial or plan ends, the school goes read-only for 15 days and the owner is emailed. The admin shows banners for read-only and offline schools.
  • A staff CLI (bun run staff) can suspend, unsuspend and import schools, recorded in platform_audit_events.

Plan features

  • A "Powered by CourseLit" badge shows on the site, in the course viewer and learner dashboard sidebars, and on learner email. The Remove CourseLit branding checkbox now saves; removing the badge needs a paid plan.
  • During a trial, injected code runs only for the owner, fetched through their own session (S25).
  • SCORM is gated to Business: no new SCORM lessons or package changes without it.
  • New audit log tab (Business). billing.* events are shown only to the owner in a browser session.
  • getSchoolPlanFeatures lets every team member's screens explain what the plan includes.

Limits

  • Marketing email:
    • SendLit team quotas are set from the plan (S20, S3).
    • Drip pauses when the quota runs out.
    • Learner mail goes through platform streams with daily caps.
  • Storage (S1, S2, S24):
    • The school is checked before an upload is sealed, the declared size is reserved at authorize, and the real size is checked at finalize under a school lock.
    • Each learner gets a budget (Settings → Storage). Learners together may use 25% of the school's storage, can't upload past 90%, and are limited to 20 uploads an hour.
    • Community uploads need a membership that can post, are always private, and are limited to images, video, audio and PDFs. Uploads no post uses are deleted after a day.
  • Invites: 50 per school per day and at most 200 pending (S6).
  • Usage: the plan page shows meters. The owner gets an 80% banner and email (getSchoolBillingUsage, owner-only and off MCP).

Abuse handling

  • "Report this site" appears on every public page and the certificate page, on every plan.
  • The docs gain an acceptable use policy and a report-abuse page.
  • Reserved subdomains (www, app, login, billing…) can't be taken.

Docs: frontlit/docs is updated for all of the above.

Fixes found along the way:

  • LearnerLink rendered a span, so storefront links didn't navigate.
  • An empty lesson told enrolled learners they weren't enrolled.

Testing

  • API suite: 718 pass. Admin, storefront and contract suites pass.
  • Browser e2e on the local stack, including:
    • a real Lemon Squeezy test checkout;
    • real MediaLit uploads;
    • trial expiry to read-only with the owner email in Mailpit;
    • the learner upload rules;
    • usage warning emails;
    • invite limits;
    • the injected-code checks for the owner and for other learners.

Before merging / follow-ups

  • GitGuardian flags a fake test key in ef14c018 (base64 of "key-key-key…"). The test now builds the key at runtime, but the incident needs marking as a false positive.
  • The abuse@courselit.app mailbox must exist.
  • Lemon Squeezy variants still need creating at the new prices ($19/$190, $79/$790).
  • Not in this PR:
    • the badge in the page-editor preview (needs a slot in FrontLit's PageBuilder);
    • school deletion;
    • the SendLit platform organization and team keys;
    • moving the QA environment to Lemon Squeezy.

@gitguardian

gitguardian Bot commented Nov 14, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 1 secret following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

🔎 Detected hardcoded secret in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
40087217 Triggered Generic Encryption Key ef14c01 apps/api/src/billing-config.test.ts View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secret safely. Learn here the best practices.
  3. Revoke and rotate this secret.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

Trial and lifecycle (docs/wip/plan-limits.md, step 4):
- the account's first school starts a 14-day trial; later schools wait
  for checkout (pending_checkout) and their site is offline
- evaluate_school_lifecycle moves schools between active, read_only and
  offline, and emails the owner once per notice
- the admin explains read-only and offline schools with a banner
- staff CLI (suspend, unsuspend, import-school, remove-imported) with
  platform_audit_events
- trial-aware plan features, and SSO enforcement kept unless a lapse is
  confirmed

Plan features (step 5):
- "Powered by CourseLit" badge under every public page, in the course
  viewer and learner dashboard sidebars, and on learner email; hidden only
  with remove_branding, saved from the admin's branding checkbox
- during a trial, injected code runs only for the owner, fetched through
  the owner's session (S25)
- SCORM lessons and package changes need the scorm feature
- audit log screen and listSchoolAuditEvents; billing events only for the
  owner in a browser session (S9, S30)
- getSchoolPlanFeatures so every team member's screens can explain limits
- LearnerLink now renders a real link; the page-builder Link primitive
  only styles text, so storefront links didn't navigate
Team, product and community invites share a rolling 24-hour budget of 50
per school, and a school can have at most 200 team invitations waiting
(docs/wip/plan-limits.md, S6). The admin explains both refusals. OSS
installs send their own mail and skip the limits.
Storage (docs/wip/plan-limits.md, S1, S2 and S24):
- the upload's school is checked before anything is sealed or deleted,
  since MediaLit's seal and delete aren't scoped to a group
- the declared size is reserved at authorize; the real size is checked
  at finalize in a transaction that locks the school, and an upload that
  doesn't fit is discarded
- each learner has an upload budget (100 MB by default, up to 1 GB in
  Settings > Storage); learners together may use 25% of the school's
  storage, nothing past 90%, and 20 uploads an hour
- community uploads need a membership that can post, are always private,
  must be an image, video, audio file or PDF, and are deleted after a day
  when no post uses them
- the admin and storefront explain refusals; learners never see numbers
- getSchoolBillingUsage returns used and allowed storage and marketing
  email (from SendLit's team usage); owner-only in a browser session and
  off MCP (S9)
- the plan page shows a meter for each limit; a trial explains that
  marketing email starts with a paid plan
- the owner sees a banner at 80% of a limit, and gets one email: for
  storage each time usage crosses 80% (again after dropping below 70%),
  for marketing email once a calendar month on a paid plan
- lifecycle emails now link to the admin's plan page (/account/billing)
…domains

Abuse handling (docs/wip/plan-limits.md, S12 and S19):
- every public storefront page and the certificate page carry "Report
  this site", on every plan, linking to a fixed CourseLit page
- an acceptable use policy and a report-abuse page in the docs, under
  Policies (reports go to abuse@courselit.app)
- reserved subdomains (www, app, api, admin, login, billing, support,
  docs and more) can't be taken; the address checker shows them as taken
Covers the free trial and plan step, usage meters and 80% warnings,
read-only schools, the "Powered by CourseLit" badge and Report this site,
trial code injection, SCORM on Business, the audit log and storage
settings, learner upload rules, invite limits and reserved addresses.
GitGuardian flagged the literal base64 test key as a hardcoded secret.

It was never a real key.
The limit counted upload reservations, which are deleted when an upload

finishes, so finished uploads stopped counting. Count authorizations in

rate_limit_events instead. Found in browser e2e.
A lesson with no content showed "You are not enrolled in the course." to

enrolled learners too. It now says the lesson has no content yet.
Previews fell through to "This lesson type is not available in this

viewer yet." for a lesson with no content.
The plan step showed only a name and a price. Each plan now lists what it

includes. Limits come from one shared definition that the API enforces.
The scroll container was the centred 640px form column, so on wide

screens its scrollbar sat inside the pane. The pane now scrolls.
- plan limits come from PLAN_{PRO,BUSINESS,TRIAL}_MARKETING_EMAILS_MONTH
  and _STORAGE (a number, a size like 500MB, or "unlimited"); all six
  are required in cloud mode, so a missing one fails startup
- the billing catalog serves them and the plan step builds its plan
  lines from them; tests set them in a preload and read them back
- pricing decided 2026-11-15: Pro $29/$290 with 25,000 emails a month
  and unlimited storage; Business $79/$790 with 100,000 emails and
  unlimited storage; the trial keeps no marketing email and 500 MB
SendLit returns a team's key only when it creates the team. The team id and key are now saved
right after creation, so a failed quota update retries with the same team instead of creating it
again, which SendLit refuses once the request has changed.
Tests that read the limits without creating a runtime failed when run on their own.
@clbotdev
clbotdev marked this pull request as ready for review November 15, 2026 13:00
A school's SendLit team is provisioned with the owner's email as
Reply-To, next to the school's name as the sender, so replies don't
land at the shared sending address.

Docs: list the organization key scopes CourseLit needs
(teams:manage, teams:keys, usage:read on top of the provisioning ones)
and the one-time setup of the school-team organization.
s3rot0n1n and others added 6 commits November 15, 2026 19:09
feat(api): send replies to a school's marketing email to its owner
The base branch now names each operation after its contract key, so the plan usage, plan
features, audit log and code injection endpoints need their operation ids in the spec.
Pro has 25,000 marketing emails a month and Business 100,000, and both have unlimited storage.
Learner mail on the platform streams now carries the school's name as its From name, at the
platform's address; staff mail stays CourseLit's. SendLit uses it once it supports a sender
name per message, and older versions ignore it. SMTP installs get the name too.
SendLit refuses them in a From name, so a school name holding one would have its learner mail
refused instead of sent under a cleaned-up name.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants