diff --git a/backend/eslint.config.mjs b/backend/eslint.config.mjs index f6ad476f..5baddfab 100644 --- a/backend/eslint.config.mjs +++ b/backend/eslint.config.mjs @@ -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/.', + }, + ], + }], }, }, ) diff --git a/backend/src/__tests__/load-entitlement-provider.test.ts b/backend/src/__tests__/load-entitlement-provider.test.ts new file mode 100644 index 00000000..81faadd5 --- /dev/null +++ b/backend/src/__tests__/load-entitlement-provider.test.ts @@ -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(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); + }); +}); diff --git a/backend/src/entitlements/loadProvider.ts b/backend/src/entitlements/loadProvider.ts index 8381db67..ac5e9864 100644 --- a/backend/src/entitlements/loadProvider.ts +++ b/backend/src/entitlements/loadProvider.ts @@ -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 { - 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); } diff --git a/backend/src/types/sencho-pro.d.ts b/backend/src/types/sencho-pro.d.ts new file mode 100644 index 00000000..ebd9fb23 --- /dev/null +++ b/backend/src/types/sencho-pro.d.ts @@ -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); + } +}