mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
feat(entitlements): wire dynamic import of @studio-saelix/sencho-pro (#880)
Phase 2 of the open-core hybrid extraction documented in
docs/internal/adrs/2026-05-02-open-core-hybrid-strategy.md. The
private @studio-saelix/sencho-pro package is now published to GitHub
Packages with v0.1.0 carrying the LemonSqueezy implementation
(LemonSqueezyEntitlementProvider). This PR delivers the public-side
hookup so the loader prefers the private package when installed and
falls back to the in-tree LicenseService when not.
loadEntitlementProvider() tries `await import('@studio-saelix/sencho-pro')`
first. If the package is missing, the loader falls back to
LicenseService.getInstance() so a Community-only build (no private
package installed, e.g. local dev or the public BSL Docker image)
still runs through the existing LemonSqueezy path. If the package
loaded but threw during construction, or if a transitive dep is
missing, the loader re-raises so the failure surfaces; silently
downgrading a paid install to community on a load-time bug would be
a license-bypass surface.
The discrimination uses two checks rather than the error code alone:
the message must include the literal package name. Without that
anchor, a missing transitive dep in a paid install would surface
with the same MODULE_NOT_FOUND code as the package itself missing.
ERR_PACKAGE_PATH_NOT_EXPORTED is intentionally NOT classified as
"not installed" because that code fires when the package was
resolved but its exports map does not include the requested path,
which is a packaging bug worth surfacing.
backend/src/types/sencho-pro.d.ts is an ambient module stub so tsc
passes when the package is not installed locally. The package's own
dist/index.d.ts shadows the stub when present; drift fails the
build. The stub uses class implements EntitlementProvider so the
interface clause carries the full method surface; we do not
redeclare individual methods.
eslint.config.mjs adds a no-restricted-imports rule blocking static
imports of @studio-saelix/sencho-pro and any subpath. The loader's
await import() is a dynamic import and is not flagged. Static
imports would bundle the package into the public BSL build via
TypeScript's module resolution, defeating the privacy split, and
would break in Community-only environments.
Adds 7 unit tests for isProPackageNotInstalled covering all the
discrimination paths: non-Error inputs, the two recognized codes,
the package-name anchor, transitive-dep MODULE_NOT_FOUND, and
ERR_PACKAGE_PATH_NOT_EXPORTED.
Test results: 91/91 backend test files pass, 1665 passing tests, 5
pre-existing skips. tsc clean. eslint 0 errors.
Out of scope for this PR (Phase 2b, separate follow-up):
- Dockerfile change to install @studio-saelix/sencho-pro from
GitHub Packages using GITHUB_TOKEN auth.
- docker-publish.yml building dual images: saelix/sencho
(Community-only) and saelix/sencho-pro (with private package).
Out of scope for this PR (cleanup, separate follow-up):
- Removing services/LicenseService.ts from the public repo.
- Switching the loader fallback from LicenseService to
CommunityEntitlementProvider.
The transitional state keeps the public repo runnable on its own
during the dual-image rollout window. The cleanup PR lands once
saelix/sencho-pro is verified working in production.
This commit is contained in:
@@ -25,6 +25,26 @@ export default tseslint.config(
|
||||
// New in ESLint 10 recommended - existing patterns, suppress until addressed
|
||||
'no-useless-assignment': 'warn',
|
||||
'preserve-caught-error': 'warn',
|
||||
// The private @studio-saelix/sencho-pro package is loaded at
|
||||
// runtime via dynamic import in entitlements/loadProvider.ts.
|
||||
// A static import would (a) bundle the package into the public
|
||||
// BSL build via TypeScript's module resolution, defeating the
|
||||
// privacy split, and (b) break in Community-only environments
|
||||
// where the package is not installed. Use loadEntitlementProvider()
|
||||
// and the registry instead.
|
||||
// no-restricted-imports flags ES `import` statements only by
|
||||
// default; dynamic `import()` expressions are not flagged unless
|
||||
// we set `allowDynamicImports: false`. The loader uses
|
||||
// `await import('@studio-saelix/sencho-pro')` and is therefore
|
||||
// not affected without an extra opt-in.
|
||||
'no-restricted-imports': ['error', {
|
||||
patterns: [
|
||||
{
|
||||
group: ['@studio-saelix/sencho-pro', '@studio-saelix/sencho-pro/*'],
|
||||
message: 'Do not statically import the private package. Use loadEntitlementProvider() and getEntitlementProvider() from src/entitlements/.',
|
||||
},
|
||||
],
|
||||
}],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Tests for the loader's module-not-found discrimination. The
|
||||
* predicate is a security boundary: a too-wide match silently
|
||||
* downgrades a paid install to community on a transitive-dependency
|
||||
* bug; a too-narrow match crashes bootstrap on a legitimate
|
||||
* Community-only build. Keep these fixtures aligned with the runtime
|
||||
* codes Node and bundlers actually produce.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isProPackageNotInstalled } from '../entitlements/loadProvider';
|
||||
|
||||
function withCode<T extends Error>(err: T, code: string): T {
|
||||
(err as Error & { code?: string }).code = code;
|
||||
return err;
|
||||
}
|
||||
|
||||
describe('isProPackageNotInstalled', () => {
|
||||
it('returns false for non-Error values', () => {
|
||||
expect(isProPackageNotInstalled(undefined)).toBe(false);
|
||||
expect(isProPackageNotInstalled(null)).toBe(false);
|
||||
expect(isProPackageNotInstalled('not an error')).toBe(false);
|
||||
expect(isProPackageNotInstalled({})).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for Error without a code', () => {
|
||||
expect(isProPackageNotInstalled(new Error('something broke'))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for ERR_MODULE_NOT_FOUND on the private package (Node ESM)', () => {
|
||||
const err = withCode(
|
||||
new Error("Cannot find package '@studio-saelix/sencho-pro' imported from /app/dist/entitlements/loadProvider.js"),
|
||||
'ERR_MODULE_NOT_FOUND',
|
||||
);
|
||||
expect(isProPackageNotInstalled(err)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for MODULE_NOT_FOUND on the private package (CJS / older Node)', () => {
|
||||
const err = withCode(
|
||||
new Error("Cannot find module '@studio-saelix/sencho-pro'"),
|
||||
'MODULE_NOT_FOUND',
|
||||
);
|
||||
expect(isProPackageNotInstalled(err)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for MODULE_NOT_FOUND on a transitive dep of the private package', () => {
|
||||
// The private package was installed but one of its dependencies is
|
||||
// missing. We must NOT classify this as "package not installed";
|
||||
// re-raising surfaces the bug instead of silently downgrading to
|
||||
// community.
|
||||
const err = withCode(
|
||||
new Error("Cannot find module 'axios'"),
|
||||
'MODULE_NOT_FOUND',
|
||||
);
|
||||
expect(isProPackageNotInstalled(err)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for ERR_PACKAGE_PATH_NOT_EXPORTED', () => {
|
||||
// Package was resolved but its exports map does not include the
|
||||
// path we asked for; this is a packaging bug, not a missing
|
||||
// package, and should re-raise.
|
||||
const err = withCode(
|
||||
new Error("Package subpath './internal' is not defined by exports in @studio-saelix/sencho-pro/package.json"),
|
||||
'ERR_PACKAGE_PATH_NOT_EXPORTED',
|
||||
);
|
||||
expect(isProPackageNotInstalled(err)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for an unrelated runtime error during construction', () => {
|
||||
// The package loaded successfully but threw inside its
|
||||
// constructor. The loader must re-raise; silently falling back
|
||||
// would be a license-bypass surface.
|
||||
const err = new TypeError('Cannot read property of undefined');
|
||||
expect(isProPackageNotInstalled(err)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,78 @@
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { LicenseService } from '../services/LicenseService';
|
||||
import type { EntitlementProvider } from './types';
|
||||
|
||||
const PRO_PACKAGE = '@studio-saelix/sencho-pro';
|
||||
|
||||
/**
|
||||
* Resolve the EntitlementProvider implementation for this build.
|
||||
*
|
||||
* Phase 1 (today): the in-tree `LicenseService` (the existing Lemon
|
||||
* Squeezy client) implements `EntitlementProvider`. We return its
|
||||
* singleton directly. No dynamic import, no fallback path.
|
||||
* Phase 2: try to dynamic-import `@studio-saelix/sencho-pro` first.
|
||||
*
|
||||
* Phase 2: this function will switch to a dynamic import that
|
||||
* distinguishes "package not installed" (fall back to Community) from
|
||||
* "package loaded but threw during construction" (re-raise). The
|
||||
* narrowing matters because silently downgrading a paid install to
|
||||
* Community on a load-time bug would be a license-bypass surface.
|
||||
* - **Module-not-found** (the package is not installed in this build,
|
||||
* e.g. the public BSL Community-only image): fall back to the
|
||||
* in-tree `LicenseService.getInstance()` so existing functionality
|
||||
* is preserved during this transition window. A later cleanup PR
|
||||
* will switch this fallback to `CommunityEntitlementProvider` and
|
||||
* remove `services/LicenseService.ts` from the public repo
|
||||
* entirely; until then, the in-tree fallback keeps the public BSL
|
||||
* repo runnable on its own.
|
||||
*
|
||||
* The function is async today so the Phase-2 swap doesn't change the
|
||||
* signature; bootstrap already awaits it.
|
||||
* - **Module loaded but threw during construction**: re-raise. A
|
||||
* silent fallback to community would be a license-bypass surface
|
||||
* (a paid install whose private package init bug downgrades them
|
||||
* to community without surfacing the failure). The bootstrap
|
||||
* should crash and the operator investigates.
|
||||
*
|
||||
* The function is async so the dynamic import does not change the
|
||||
* call-site contract.
|
||||
*/
|
||||
export async function loadEntitlementProvider(): Promise<EntitlementProvider> {
|
||||
return LicenseService.getInstance();
|
||||
try {
|
||||
const mod = await import(PRO_PACKAGE);
|
||||
// Explicit typing so a structural drift between Sencho's
|
||||
// DatabaseService and the package's DatabaseAdapter (e.g. a
|
||||
// future widening of the adapter interface) fails type-check
|
||||
// here rather than only at production-CI dual-image build
|
||||
// time. Sencho's DatabaseService satisfies the adapter shape
|
||||
// structurally today.
|
||||
const db: import('@studio-saelix/sencho-pro').DatabaseAdapter = DatabaseService.getInstance();
|
||||
return new mod.LemonSqueezyEntitlementProvider(db);
|
||||
} catch (err) {
|
||||
if (isProPackageNotInstalled(err)) {
|
||||
// Phase 2 transitional fallback: keep the in-tree
|
||||
// LicenseService binding so a Community-only build (no
|
||||
// private package installed) still runs through the
|
||||
// existing LemonSqueezy path. A follow-up PR will replace
|
||||
// this with a CommunityEntitlementProvider and remove
|
||||
// services/LicenseService.ts from the public repo.
|
||||
return LicenseService.getInstance();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinguish "the private package itself is not installed" (legitimate
|
||||
* fallback path) from "the package loaded but threw / a transitive dep
|
||||
* is missing" (re-raise so the operator sees the failure).
|
||||
*
|
||||
* The error code alone is not specific enough: a missing transitive
|
||||
* dependency in an installed paid package surfaces with the same
|
||||
* `MODULE_NOT_FOUND` code as the package itself missing. We additionally
|
||||
* require the error message to mention the private package by name, so
|
||||
* the fallback only triggers when Node could not resolve
|
||||
* `@studio-saelix/sencho-pro` at the top level.
|
||||
*
|
||||
* Codes covered: `ERR_MODULE_NOT_FOUND` (Node >=20 ESM), `MODULE_NOT_FOUND`
|
||||
* (CJS / older Node). `ERR_PACKAGE_PATH_NOT_EXPORTED` is intentionally
|
||||
* NOT treated as "not installed": that fires when the package was
|
||||
* resolved but its exports map does not include the path we asked for,
|
||||
* which is a packaging bug we want to surface, not silently downgrade.
|
||||
*/
|
||||
export function isProPackageNotInstalled(err: unknown): boolean {
|
||||
if (!(err instanceof Error)) return false;
|
||||
const code = (err as Error & { code?: string }).code;
|
||||
if (code !== 'ERR_MODULE_NOT_FOUND' && code !== 'MODULE_NOT_FOUND') return false;
|
||||
return err.message.includes(PRO_PACKAGE);
|
||||
}
|
||||
|
||||
Vendored
+34
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Ambient declaration for the private `@studio-saelix/sencho-pro`
|
||||
* package so the public Sencho core's TypeScript build passes whether
|
||||
* or not the package is installed locally. The runtime shape is
|
||||
* defined by the package itself; this stub mirrors the surface the
|
||||
* loader uses and nothing more.
|
||||
*
|
||||
* In production CI the Dockerfile installs the real package; the stub
|
||||
* is shadowed by the package's own types and any drift fails the
|
||||
* build. Local development without GitHub Packages auth falls back to
|
||||
* the stub plus the loader's in-tree LicenseService binding, which
|
||||
* keeps `npm install` and `tsc` working for everyone.
|
||||
*
|
||||
* The `implements EntitlementProvider` clause carries the full method
|
||||
* surface; we do not redeclare individual methods here. If the real
|
||||
* package ever ships a narrower return type than the interface
|
||||
* permits, the local stub would over-widen and lose call-site type
|
||||
* info — but call sites only consume the interface (via the registry),
|
||||
* so that risk does not materialise.
|
||||
*/
|
||||
declare module '@studio-saelix/sencho-pro' {
|
||||
import type { EntitlementProvider } from '../entitlements/types';
|
||||
|
||||
/** Minimal database surface the provider needs. The public core's
|
||||
* `DatabaseService` satisfies this structurally. */
|
||||
export interface DatabaseAdapter {
|
||||
getSystemState(key: string): string | null;
|
||||
setSystemState(key: string, value: string): void;
|
||||
}
|
||||
|
||||
export class LemonSqueezyEntitlementProvider implements EntitlementProvider {
|
||||
constructor(db: DatabaseAdapter);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user