mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 16:37:46 +00:00
3324616e59
* refactor(backend): extract EntitlementProvider abstraction (Phase 1)
Phase 1 of the open-core hybrid extraction described in
docs/internal/adrs/2026-05-02-open-core-hybrid-strategy.md. Introduces
the abstraction without moving any code out of the public repo; Phase
2 will actually move services/LicenseService.ts to a private
@studio-saelix/sencho-pro package.
The new backend/src/entitlements/ module contains:
- types.ts. The EntitlementProvider interface plus all tier/license
types (LicenseTier, LicenseVariant, LicenseInfo, SeatLimits,
ActivationResult, etc.). The interface mirrors the existing
LicenseService public surface so the migration was mechanical.
- registry.ts. Module-scope holder for the active provider with
setEntitlementProvider, getEntitlementProvider, and a test-only
reset helper. getEntitlementProvider throws if called before
bootstrap registers a provider; the throw is intentional fail-fast
on a bootstrap-order bug rather than a silent degradation.
- CommunityEntitlementProvider.ts. Phase 2 fallback that returns
community tier and rejects activate(). NOT instantiated in
production today; a smoke test keeps it covered against bitrot.
- loadProvider.ts. Async resolver. Phase 1 returns
LicenseService.getInstance() directly. The async signature matches
what Phase 2 needs (dynamic import of @studio-saelix/sencho-pro
with a "module not found" vs "construction threw" narrowing); the
call site does not change between phases.
- headers.ts. PROXY_TIER_HEADER and PROXY_VARIANT_HEADER constants.
These are part of the wire contract between Sencho instances and
belong in the public core regardless of which entitlement provider
is bound.
- normalize.ts. isLicenseTier, isLicenseVariant, normalizeTier,
normalizeVariant. Domain knowledge about Sencho's tier model
(legacy name maps from pre-0.38.1 versions), not LemonSqueezy
internals. Phase 2 keeps these in the public core.
services/LicenseService.ts now imports its types from
entitlements/types and adds an "implements EntitlementProvider"
clause. Re-exports the types for back-compat with ~20 type-only
consumers; a follow-up PR will sweep those imports to entitlements/
types directly before Phase 2 deletes the file.
bootstrap/startup.ts awaits loadEntitlementProvider, registers the
result, then calls initialize. shutdown.ts calls
getEntitlementProvider().destroy() instead of the LicenseService
singleton.
middleware/tierGates.ts, the chokepoint for ~154 tier-check call
sites, now reads through getEntitlementProvider. Sixteen other
production files (routes/{fleet,imageUpdates,license,permissions,
scheduledTasks,security,stacks,templates,users,webhooks},
services/{BlueprintService,CloudBackupService,SchedulerService,
SSOService}, proxy/remoteNodeProxy, websocket/{hostConsole,
remoteForwarder}, middleware/auth) had their LicenseService.getInstance
calls and utility-export imports redirected to the entitlements
module. The only remaining LicenseService.getInstance in production
code is in entitlements/loadProvider.ts itself, which is the
intentional Phase-1 binding site.
Test infrastructure: setupTestDb registers
LicenseService.getInstance() as the active provider so existing
test files using the helper need no changes. The mocking pattern
many tests use, vi.spyOn(LicenseService.getInstance(), 'getTier'),
keeps working because LicenseService.getInstance() and
getEntitlementProvider() return the same singleton in Phase 1.
scheduler-service.test.ts is the only test that does not use
setupTestDb but exercises tier-gating; it now mocks
entitlements/registry alongside its existing LicenseService mock.
Adds a smoke test for CommunityEntitlementProvider so the Phase 2
fallback class stays covered.
Adds an architecture doc at
docs/internal/architecture/entitlement-provider.md covering the
runtime registry, bootstrap order invariants, and the Phase 1 vs
Phase 2 binding table.
Test results: 89/89 backend test files pass, 1657 passing tests, 5
pre-existing skips. The pre-existing database-metrics > handles
1000+ metrics stress test continues to flake under parallel load
and pass when re-run solo, same flake observed in PRs #862, #863.
* chore(backend): drop unused entitlement type imports from LicenseService
Phase 1 of the EntitlementProvider extraction left five type imports
(ActivationResult, BillingPortalError, BillingPortalResult,
DeactivationResult, ValidationResult) unreferenced after the runtime
methods that produced them began inferring their result shapes via the
EntitlementProvider interface contract. ESLint's no-unused-vars rule
flagged them as errors and failed the lint step in CI.