Fresh installs land on the Community tier. The 14-day Admiral trial is now
issued by Lemon Squeezy via their hosted checkout: the user enters email +
card, receives a license key by email, and pastes it into the existing
Settings > License activation field.
Backend changes:
- LicenseService.initialize() no longer auto-creates a license_status='trial'
row on first boot. It now only ensures an instance_id exists and starts
periodic validation.
- Drop the TRIAL_DURATION_DAYS constant.
- Drop the status='trial' early-return in getVariant() so LS-issued trials
resolve through the normal variant metadata path (variant_name / product_name).
- Trial branches in getTier() and getLicenseInfo() are retained for future
work that may detect trial state from Lemon Squeezy metadata; they are
currently unreachable via the Sencho code paths.
Frontend changes:
- Settings > License surfaces a new "Try Admiral free for 14 days" CTA block
with Start monthly trial and Start annual trial buttons that open Lemon
Squeezy hosted checkout. The CTA is visible only when the user has no paid
access and is not already on a trial.
- Reserve the Admiral upgrade card for the Skipper-active upgrade path so
unlicensed users see one Admiral path (the trial CTA) instead of two.
- Pull the inline Lemon Squeezy checkout URLs into named module constants so
the Skipper, Admiral monthly, and Admiral annual endpoints are defined in
one place.
Test changes:
- license-service.test.ts covers the no-auto-trial startup path and updates
the trial-variant test to reflect the metadata-driven resolution.
- afterAll in the initialize() describe block calls destroy() so the 72-hour
validation interval does not leak into sibling test files.
Docs:
- Rewrite the Free trial section in features/licensing.mdx to document the
new LS checkout flow (email + card required, auto-converts on day 14 unless
cancelled).
- Add an operations/troubleshooting entry for cases where the trial license
key email does not arrive.
Lifetime licenses have no recurring subscription, so the Lemon Squeezy
customer portal cannot generate a URL. The Manage Subscription button in
Settings already had the isLifetime guard, but the Billing button in the
profile dropdown did not, causing a confusing "No billing portal
available" error.
- Add !license.isLifetime guard to UserProfileDropdown (matches
LicenseSection pattern)
- Move lifetime detection into getBillingPortalUrl() so the service owns
all billing eligibility logic
- Change return type to { url } | { error } discriminated union for
clear error propagation
Accept legacy tier ('pro') and variant ('personal', 'team') names from older
remote nodes, normalizing them to current values ('paid', 'skipper', 'admiral')
in authMiddleware. This fixes distributed license enforcement failing between
v0.38.3 and v0.38.0 nodes due to the tier rename in v0.38.1.
Also fixes:
- Self-healing getVariant() that cross-checks stored variant_type against
product/variant name metadata on every call, correcting stale cached values
from previous buggy resolution logic
- Unguarded API responses in ResourcesView causing potential t.map crashes
- Fleet update status now polls on a 120s interval (was only fetched on mount)
- fetchRemoteMeta failures now logged for diagnosability
Lemon Squeezy variant names describe billing periods ("Lifetime",
"Monthly", "Annual") without containing the tier name. resolveVariantType
now checks both variant_name and product_name (e.g. "Sencho Admiral") to
correctly identify the tier. Fixes Admiral licenses being misidentified
as Skipper when the LS variant name is just "Lifetime".
Rename internal variant values from 'personal'/'team' to 'skipper'/'admiral',
aligning code with user-facing tier names. Variant type is now resolved once
at activation/validation and stored in DB via license_variant_type, instead
of string-matching the Lemon Squeezy variant_name on every read. Also captures
variant_id for future lookups. Pre-existing installs auto-migrate on first
getVariant() call.
* fix(licensing): resolve Admiral variant detection and lifetime license handling
The Lemon Squeezy variant name for Admiral licenses contains "Admiral"
(not "Team"), but getVariant() only checked for "team" and "personal".
This caused Admiral licenses to be misidentified as Skipper, locking all
Admiral-exclusive features.
- Map "admiral" variant names to internal "team" value, "skipper" to "personal"
- Add isLifetime field to LicenseInfo API response
- Hide "Manage Subscription" button for lifetime licenses (no billing portal)
- Show "Duration: Lifetime" instead of empty renewal date
- Hide upgrade cards for active Admiral users
- Add 23 unit tests covering variant resolution, tier computation, and lifetime detection
- Add troubleshooting entries for wrong tier label, locked features, and billing portal errors
* fix(licensing): address code review findings
- Fix nested ternary in LicenseSection JSX; restore conditional rendering
to avoid showing an empty "N/A" row for non-subscription states
- Clean up test file: use shared svc variable, remove redundant comments,
add trialDaysRemaining assertions, rename describe block
Eliminate all references to "Pro" across backend, frontend, and docs.
Internal tier value renamed from 'pro' to 'paid'; user-facing text now
uses the thematic tier names (Community, Skipper, Admiral).
- Rename LicenseTier 'pro' to 'paid' in backend and frontend types
- Rename requirePro guard to requirePaid, error code PRO_REQUIRED to PAID_REQUIRED
- Rename ProGate.tsx to PaidGate.tsx with updated copy
- Fix: trial users can now see upgrade/purchase cards in Settings
- Update all docs and openapi.yaml to use correct tier names
* feat(license): distributed license enforcement across multi-node setups
The primary instance's license tier is now asserted to remote nodes on
every proxied HTTP and WebSocket request via trusted headers. Remote
nodes honor the assertion only when the request carries a valid
node_proxy JWT, preventing unauthorized elevation from browsers or API
tokens. Falls back to local license tier for direct access.
* fix(test): remove unused vi import in distributed-license tests
Adds the ability to schedule recurring Docker operations (stack restarts,
fleet snapshots, system prunes) via cron expressions with full execution
history logging. Includes Run Now for on-demand execution.
* feat: add RBAC viewer accounts, atomic deployments, and fleet-wide backups (Pro)
Introduces three Pro-tier features:
- RBAC: Multi-user system with admin/viewer roles, user management UI,
automatic migration from single-admin credentials, viewer restrictions
across the entire UI (read-only editor, hidden action buttons)
- Atomic Deployments: Pre-deploy file backup to .sencho-backup/, automatic
rollback on health probe failure, manual rollback button, health probes
added to stack updates, webhook-triggered deploys use atomic rollback
- Fleet-Wide Backups: Point-in-time snapshots of compose files across all
nodes (local + remote), stored centrally in SQLite, per-stack restore
with optional redeploy, graceful handling of offline nodes
* fix(settings): use correct ProGate prop name in UsersSection
* fix(settings): remove unused isPro prop from UsersSection
* fix(auth): fetch user info after login and setup so isAdmin is set correctly
* feat(pricing): revise pricing strategy and enforce variant-based seat limits
Raise Personal Pro from $49/yr to $69/yr with 3 viewer seats (up from 1).
Add $15/mo billing option for Team Pro. Mark lifetime pricing as a
90-day early-adopter offer. Store Lemon Squeezy variant_name on
activation/validation and enforce seat limits server-side per variant.
* feat(licensing): add Lemon Squeezy checkout, webhook, and billing portal integration
Server-side checkout URL generation (POST /api/checkout) with admin email
pre-fill and instance_id custom data. HMAC-SHA256 verified webhook endpoint
(POST /api/webhooks/lemonsqueezy) handling order, subscription, and payment
lifecycle events for automatic license activation. Customer billing portal
link stored from webhook events and exposed via GET /api/billing/portal.
In-app checkout buttons in Settings with manual license key fallback.
* fix(licensing): exempt Lemon Squeezy webhook from auth middleware
The catch-all auth middleware on /api/* was blocking the public webhook
endpoint. Added /webhooks/lemonsqueezy to the exemption list alongside
/auth/* and /webhooks/:id/trigger.
* feat(pricing): update pricing to final live rates
Personal Pro: $7.99/month, $69.99/year, $249 lifetime.
Team Pro: $49.99/month, $499.99/year, $1,499 lifetime.
Added personal_monthly checkout variant across backend, frontend, and website.
* refactor(licensing): remove server-side checkout/webhook for self-hosted model
Sencho is self-hosted — each user runs their own instance, so there is
no central server to receive webhooks or hold the store API key. Replaced
in-app checkout buttons with a "View Pricing" redirect to sencho.io and
kept manual license key activation as the primary flow.
- Delete LemonSqueezyService (checkout, webhook, HMAC verification)
- Remove POST /api/checkout, GET /api/billing/portal, POST /api/webhooks/lemonsqueezy
- Remove raw body parser and auth exemption for webhook route
- Remove all LEMONSQUEEZY_* env vars from .env.example
- Replace checkout buttons in SettingsModal with single "View Pricing" button
- Simplify LicenseContext checkout to open sencho.io pricing page
- Update licensing docs to reflect website-based purchase flow
* chore: normalize em-dashes to hyphens across codebase (linter)
* chore: remove accidentally tracked directories from index
* feat: add license gating system with Lemon Squeezy integration
Add Community/Pro tier infrastructure:
- LicenseService singleton with Lemon Squeezy license API integration
- /api/license endpoints (GET info, POST activate/deactivate/validate)
- 14-day Pro trial activated automatically on first boot
- 72-hour periodic validation with 30-day offline grace period
- LicenseContext provider for frontend tier awareness
- License settings tab with activation UI and status display
- ProBadge and ProGate reusable components for feature gating
- requirePro per-route guard for backend Pro-only endpoints
- Proxy bypass for /api/license routes (local-only, never proxied)
* feat: add user profile dropdown and reorganize top navigation
- Create UserProfileDropdown component with settings, billing, theme
toggle (System/Light/Dark), documentation links, and logout button
- Remove logout button from sidebar header
- Remove standalone settings button from top bar
- Move theme toggle from Settings modal to profile dropdown
- Inject app version via Vite define from root package.json
- Add globals.d.ts for __APP_VERSION__ type declaration
* refactor(settings): remove appearance tab from settings modal
Theme toggle was moved to the User Profile Dropdown in the previous
commit. Remove the now-redundant Appearance section, its nav button,
and the unused theme/setTheme props from SettingsModal.
* feat: add fleet view dashboard and about settings section
Fleet Overview: aggregates all nodes into a card grid showing status,
container counts, CPU/RAM/disk usage bars. Pro tier unlocks stack
drill-down with auto-refresh (30s). Backend endpoints /api/fleet/overview
and /api/fleet/node/:nodeId/stacks query nodes in parallel.
About section in Settings: displays version, license tier, status,
instance ID, and links to docs/changelog/issues.
Sidebar perf fix: stack status fetches now run in parallel via
Promise.allSettled instead of sequential for-loop, significantly
reducing load time for nodes with many stacks.
Also removes version number from User Profile Dropdown (now in About).
* fix(ci): resolve Docker build and E2E test failures
- Copy root package.json into frontend build stage so vite.config.ts
can read the app version during Docker multi-stage build.
- Update auth E2E test: logout button moved into User Profile Dropdown.
- Update nodes E2E test: Settings button moved into User Profile Dropdown.