@bhooai/nexus-crypto 2.0.15 → 2.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +15 -2
- package/src/license-browser.ts +59 -0
- package/src/license.ts +245 -89
- package/tests/license.test.ts +149 -61
package/package.json
CHANGED
|
@@ -1,17 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bhooai/nexus-crypto",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.16",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "./src/index.ts",
|
|
9
9
|
"types": "./src/index.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./src/index.ts",
|
|
13
|
+
"default": "./src/index.ts"
|
|
14
|
+
},
|
|
15
|
+
"./browser": {
|
|
16
|
+
"types": "./src/license-browser.ts",
|
|
17
|
+
"default": "./src/license-browser.ts"
|
|
18
|
+
},
|
|
19
|
+
"./package.json": "./package.json"
|
|
20
|
+
},
|
|
10
21
|
"scripts": {
|
|
11
22
|
"build": "tsc -p tsconfig.json",
|
|
12
23
|
"test": "vitest run"
|
|
13
24
|
},
|
|
14
|
-
"dependencies": {
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"jose": "^5.9.6"
|
|
27
|
+
},
|
|
15
28
|
"devDependencies": {
|
|
16
29
|
"@types/node": "^22.5.0",
|
|
17
30
|
"typescript": "^5.6.2",
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-safe license gate.
|
|
3
|
+
*
|
|
4
|
+
* The real key derivation in `./license.ts` needs Node's `crypto`/`fs`/`os`,
|
|
5
|
+
* so client bundles (the admin SPA, safe-goto) can't import it. This variant
|
|
6
|
+
* only asserts that a master key has been injected into the client by the
|
|
7
|
+
* host application — via `import.meta.env.VITE_NEXUS_LICENSE_KEY`,
|
|
8
|
+
* `globalThis.NEXUS_LICENSE_KEY`, `globalThis.__NEXUS_LICENSE_KEY__`, or
|
|
9
|
+
* `process.env.NEXUS_LICENSE_KEY` (bundler-injected).
|
|
10
|
+
*
|
|
11
|
+
* Keys are minted/verified by the authority at nexus-bhooai-com
|
|
12
|
+
* (`POST /api/license-verify`); the browser only consumes the key.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const LICENSE_ENV_VAR = 'NEXUS_LICENSE_KEY';
|
|
16
|
+
|
|
17
|
+
/** Machine-readable license failure. Mirrors the Node `LicenseError`. */
|
|
18
|
+
export class LicenseError extends Error {
|
|
19
|
+
readonly code = 'LICENSE_REQUIRED';
|
|
20
|
+
constructor(
|
|
21
|
+
packageName: string,
|
|
22
|
+
readonly hint = 'No license found — get a key at https://nexus.bhooai.com/account, then expose it to the app as VITE_NEXUS_LICENSE_KEY (or globalThis.NEXUS_LICENSE_KEY).',
|
|
23
|
+
) {
|
|
24
|
+
super(`[${packageName}] ${hint}`);
|
|
25
|
+
this.name = 'LicenseError';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readBrowserKey(): string | null {
|
|
30
|
+
const g = globalThis as unknown as Record<string, unknown>;
|
|
31
|
+
const env = (import.meta as unknown as { env?: Record<string, unknown> } | undefined)?.env;
|
|
32
|
+
const processEnv = (g.process as { env?: Record<string, unknown> } | undefined)?.env;
|
|
33
|
+
const candidates = [
|
|
34
|
+
env?.VITE_NEXUS_LICENSE_KEY,
|
|
35
|
+
env?.NEXUS_LICENSE_KEY,
|
|
36
|
+
g.NEXUS_LICENSE_KEY,
|
|
37
|
+
g.__NEXUS_LICENSE_KEY__,
|
|
38
|
+
processEnv?.NEXUS_LICENSE_KEY,
|
|
39
|
+
];
|
|
40
|
+
for (const c of candidates) {
|
|
41
|
+
if (typeof c === 'string' && c.trim()) return c.trim();
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** True when a master key is available to the client. */
|
|
47
|
+
export function hasLicense(): boolean {
|
|
48
|
+
return readBrowserKey() !== null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Gate for licensed client features. Returns the injected key, or throws a
|
|
53
|
+
* "add license first" LicenseError when none is exposed to the client.
|
|
54
|
+
*/
|
|
55
|
+
export function requireLicense(packageName: string): string {
|
|
56
|
+
const key = readBrowserKey();
|
|
57
|
+
if (!key) throw new LicenseError(packageName);
|
|
58
|
+
return key;
|
|
59
|
+
}
|
package/src/license.ts
CHANGED
|
@@ -1,141 +1,297 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* License core —
|
|
2
|
+
* License core — authority-signed tokens, verified online each run.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* The authority (nexus-bhooai-com) signs a compact JWS license token with a
|
|
5
|
+
* private key that never leaves the authority. This package only holds the
|
|
6
|
+
* *verification* side: it fetches the authority's public keys (JWKS), verifies
|
|
7
|
+
* the token signature offline, then confirms current validity with the
|
|
8
|
+
* authority online. A user cannot forge a valid token from this code — they
|
|
9
|
+
* would need the authority's private key.
|
|
9
10
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
11
|
+
* Flow:
|
|
12
|
+
* - `nexus license add` (browser) stores the token at
|
|
13
|
+
* `<projectRoot>/license/license.jwt`.
|
|
14
|
+
* - Each run calls `ensureLicense()`, which verifies the token (signature via
|
|
15
|
+
* fetched JWKS + online status) and caches the result in memory for the run.
|
|
16
|
+
* - Gated features call `requireLicense()` (sync, reads the in-memory result).
|
|
17
|
+
*
|
|
18
|
+
* Lookup order: `NEXUS_LICENSE_KEY` env (CI/containers) →
|
|
19
|
+
* `<projectRoot>/license/license.jwt`. The token is a bearer credential, so the
|
|
20
|
+
* `license/` folder should be gitignored.
|
|
12
21
|
*/
|
|
13
|
-
import {
|
|
14
|
-
import { homedir } from 'node:os';
|
|
22
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
15
23
|
import { join } from 'node:path';
|
|
16
|
-
import {
|
|
24
|
+
import { createLocalJWKSet, jwtVerify, type JSONWebKeySet } from 'jose';
|
|
17
25
|
|
|
18
26
|
export const LICENSE_ENV_VAR = 'NEXUS_LICENSE_KEY';
|
|
19
|
-
export const
|
|
20
|
-
export const
|
|
21
|
-
export const
|
|
22
|
-
export const
|
|
23
|
-
|
|
27
|
+
export const LICENSE_SERVER_ENV_VAR = 'NEXUS_LICENSE_SERVER';
|
|
28
|
+
export const LICENSE_FILE_NAME = 'license.jwt';
|
|
29
|
+
export const LICENSE_DIR_NAME = 'license';
|
|
30
|
+
export const LICENSE_ISSUER = 'nexus-bhooai-com';
|
|
31
|
+
|
|
32
|
+
/** Default authority. Override per-project/CI via `NEXUS_LICENSE_SERVER`. */
|
|
33
|
+
export const DEFAULT_LICENSE_SERVER = 'https://nexus.bhooai.com';
|
|
34
|
+
|
|
35
|
+
/** How long a fetched JWKS is cached in memory before re-fetching. */
|
|
36
|
+
export const JWKS_TTL_MS = 60 * 60 * 1000;
|
|
37
|
+
|
|
38
|
+
/** License token claims (subset we rely on). */
|
|
39
|
+
export interface LicenseClaims {
|
|
40
|
+
iss?: string;
|
|
41
|
+
sub?: string;
|
|
42
|
+
kid?: string;
|
|
43
|
+
plan?: string;
|
|
44
|
+
seats?: number;
|
|
45
|
+
keyHash?: string;
|
|
46
|
+
iat?: number;
|
|
47
|
+
exp?: number;
|
|
48
|
+
jti?: string;
|
|
49
|
+
[k: string]: unknown;
|
|
50
|
+
}
|
|
24
51
|
|
|
25
52
|
/** Machine-readable license failure. Packages surface this at gated entry points. */
|
|
26
53
|
export class LicenseError extends Error {
|
|
27
54
|
readonly code = 'LICENSE_REQUIRED';
|
|
28
55
|
constructor(
|
|
29
56
|
packageName: string,
|
|
30
|
-
readonly hint =
|
|
57
|
+
readonly hint = 'No valid license — run `nexus license add` (or set NEXUS_LICENSE_KEY to a signed license token) and retry.',
|
|
31
58
|
) {
|
|
32
59
|
super(`[${packageName}] ${hint}`);
|
|
33
60
|
this.name = 'LicenseError';
|
|
34
61
|
}
|
|
35
62
|
}
|
|
36
63
|
|
|
37
|
-
export
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
64
|
+
export type LicenseSource = 'env' | 'file';
|
|
65
|
+
|
|
66
|
+
export interface ResolvedLicense {
|
|
67
|
+
token: string;
|
|
68
|
+
source: LicenseSource;
|
|
69
|
+
file?: string;
|
|
41
70
|
}
|
|
42
71
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
try {
|
|
47
|
-
files.push(join(homedir(), LICENSE_DIR_NAME, LICENSE_FILE_NAME));
|
|
48
|
-
} catch {
|
|
49
|
-
// homedir() can throw in restricted environments — env + project file still work
|
|
50
|
-
}
|
|
51
|
-
return files;
|
|
72
|
+
interface ResolveOptions {
|
|
73
|
+
projectDir?: string;
|
|
74
|
+
env?: NodeJS.ProcessEnv;
|
|
52
75
|
}
|
|
53
76
|
|
|
54
|
-
/**
|
|
55
|
-
export function
|
|
56
|
-
|
|
57
|
-
if (!existsSync(path)) return null;
|
|
58
|
-
const raw = JSON.parse(readFileSync(path, 'utf8')) as Partial<StoredLicense>;
|
|
59
|
-
if (typeof raw.key !== 'string' || !raw.key) return null;
|
|
60
|
-
return { key: raw.key, addedAt: typeof raw.addedAt === 'string' ? raw.addedAt : '', server: raw.server };
|
|
61
|
-
} catch {
|
|
62
|
-
return null;
|
|
63
|
-
}
|
|
77
|
+
/** Path to a project's license token file. */
|
|
78
|
+
export function licenseFilePath(projectDir: string): string {
|
|
79
|
+
return join(projectDir, LICENSE_DIR_NAME, LICENSE_FILE_NAME);
|
|
64
80
|
}
|
|
65
81
|
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
-
* Returns null when no key is configured anywhere.
|
|
69
|
-
*/
|
|
70
|
-
export function loadMasterKey(opts: { projectDir?: string; env?: NodeJS.ProcessEnv } = {}): string | null {
|
|
82
|
+
/** Resolve the license token and where it came from, without touching the network. */
|
|
83
|
+
export function resolveLicenseToken(opts: ResolveOptions = {}): ResolvedLicense | null {
|
|
71
84
|
const env = opts.env ?? process.env;
|
|
72
85
|
const fromEnv = (env[LICENSE_ENV_VAR] ?? '').trim();
|
|
73
|
-
if (fromEnv) return fromEnv;
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
86
|
+
if (fromEnv) return { token: fromEnv, source: 'env' };
|
|
87
|
+
|
|
88
|
+
if (opts.projectDir) {
|
|
89
|
+
const file = licenseFilePath(opts.projectDir);
|
|
90
|
+
if (existsSync(file)) {
|
|
91
|
+
try {
|
|
92
|
+
const token = readFileSync(file, 'utf8').trim();
|
|
93
|
+
if (token) return { token, source: 'file', file };
|
|
94
|
+
} catch {
|
|
95
|
+
// unreadable → treated as no license
|
|
96
|
+
}
|
|
97
|
+
}
|
|
77
98
|
}
|
|
78
99
|
return null;
|
|
79
100
|
}
|
|
80
101
|
|
|
81
|
-
/**
|
|
82
|
-
export function
|
|
83
|
-
const dir = join(
|
|
102
|
+
/** Store a signed token into the project's `license/` folder. */
|
|
103
|
+
export function storeLicenseToken(projectDir: string, token: string): string {
|
|
104
|
+
const dir = join(projectDir, LICENSE_DIR_NAME);
|
|
84
105
|
mkdirSync(dir, { recursive: true });
|
|
85
|
-
const
|
|
86
|
-
writeFileSync(
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
106
|
+
const file = join(dir, LICENSE_FILE_NAME);
|
|
107
|
+
writeFileSync(file, token.trim() + '\n', 'utf8');
|
|
108
|
+
return file;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Remove a project's stored license token (and any legacy files). */
|
|
112
|
+
export function removeLicense(projectDir: string): boolean {
|
|
113
|
+
const dir = join(projectDir, LICENSE_DIR_NAME);
|
|
114
|
+
let removed = false;
|
|
115
|
+
for (const name of [LICENSE_FILE_NAME, 'license.json', 'license.lic']) {
|
|
116
|
+
const p = join(dir, name);
|
|
117
|
+
if (existsSync(p)) {
|
|
118
|
+
rmSync(p);
|
|
119
|
+
removed = true;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const legacy = join(projectDir, '.nexus-license.json');
|
|
123
|
+
if (existsSync(legacy)) {
|
|
124
|
+
rmSync(legacy);
|
|
125
|
+
removed = true;
|
|
126
|
+
}
|
|
127
|
+
return removed;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Resolve the authority base URL (env override → default). */
|
|
131
|
+
export function getLicenseServer(env: NodeJS.ProcessEnv = process.env): string {
|
|
132
|
+
return (env[LICENSE_SERVER_ENV_VAR] ?? '').trim().replace(/\/+$/, '') || DEFAULT_LICENSE_SERVER;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
type LocalJwks = ReturnType<typeof createLocalJWKSet>;
|
|
136
|
+
|
|
137
|
+
let jwksCache: { server: string; jwks: LocalJwks; fetchedAt: number } | null = null;
|
|
138
|
+
|
|
139
|
+
/** Fetch (and memory-cache) the authority's public keys as a verifier. */
|
|
140
|
+
export async function fetchLicenseJwks(
|
|
141
|
+
server: string,
|
|
142
|
+
fetchImpl: typeof fetch = fetch,
|
|
143
|
+
force = false,
|
|
144
|
+
): Promise<LocalJwks> {
|
|
145
|
+
const base = server.replace(/\/+$/, '');
|
|
146
|
+
if (!force && jwksCache && jwksCache.server === base && Date.now() - jwksCache.fetchedAt < JWKS_TTL_MS) {
|
|
147
|
+
return jwksCache.jwks;
|
|
148
|
+
}
|
|
149
|
+
const url = `${base}/.well-known/nexus-license-jwks.json`;
|
|
150
|
+
let res: Response;
|
|
91
151
|
try {
|
|
92
|
-
|
|
152
|
+
res = await fetchImpl(url, { headers: { accept: 'application/json' } });
|
|
93
153
|
} catch {
|
|
94
|
-
|
|
154
|
+
throw new Error(`Cannot reach the license authority at ${base} — check your connection.`);
|
|
95
155
|
}
|
|
96
|
-
|
|
156
|
+
if (!res.ok) throw new Error(`License JWKS request failed (HTTP ${res.status}) from ${url}.`);
|
|
157
|
+
const body = (await res.json().catch(() => ({}))) as JSONWebKeySet;
|
|
158
|
+
const jwks = createLocalJWKSet(body);
|
|
159
|
+
jwksCache = { server: base, jwks, fetchedAt: Date.now() };
|
|
160
|
+
return jwks;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Verify a signed token's signature + issuer + expiry. Throws on failure. */
|
|
164
|
+
export async function verifyLicenseToken(token: string, jwks: LocalJwks): Promise<LicenseClaims> {
|
|
165
|
+
const { payload } = await jwtVerify(token, jwks, { issuer: LICENSE_ISSUER });
|
|
166
|
+
return payload as LicenseClaims;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface VerifyResult {
|
|
170
|
+
valid: boolean;
|
|
171
|
+
plan?: string;
|
|
172
|
+
expiresAt?: string | null;
|
|
173
|
+
message?: string;
|
|
97
174
|
}
|
|
98
175
|
|
|
99
|
-
/**
|
|
100
|
-
export function
|
|
176
|
+
/** Confirm current validity with the authority (revocation, expiry, seats). */
|
|
177
|
+
export async function verifyLicenseOnline(
|
|
178
|
+
server: string,
|
|
179
|
+
token: string,
|
|
180
|
+
fetchImpl: typeof fetch = fetch,
|
|
181
|
+
): Promise<VerifyResult> {
|
|
182
|
+
const url = `${server.replace(/\/+$/, '')}/api/license-verify`;
|
|
183
|
+
let res: Response;
|
|
101
184
|
try {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
185
|
+
res = await fetchImpl(url, {
|
|
186
|
+
method: 'POST',
|
|
187
|
+
headers: { 'Content-Type': 'application/json' },
|
|
188
|
+
body: JSON.stringify({ token }),
|
|
189
|
+
});
|
|
106
190
|
} catch {
|
|
107
|
-
|
|
191
|
+
throw new Error(`Cannot reach the license server at ${server} — check your connection.`);
|
|
108
192
|
}
|
|
193
|
+
const data = (await res.json().catch(() => ({}))) as Partial<VerifyResult> & { error?: { message?: string } };
|
|
194
|
+
if (!res.ok) throw new Error(data.error?.message ?? `License server responded with HTTP ${res.status}.`);
|
|
195
|
+
return { valid: !!data.valid, plan: data.plan, expiresAt: data.expiresAt ?? null, message: data.message };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Per-process license state, set by `ensureLicense()` at boot. */
|
|
199
|
+
interface RunLicenseState {
|
|
200
|
+
status: 'valid' | 'invalid';
|
|
201
|
+
claims?: LicenseClaims;
|
|
202
|
+
message?: string;
|
|
203
|
+
checkedAt: number;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let runState: RunLicenseState | null = null;
|
|
207
|
+
|
|
208
|
+
/** The verified claims for this run, or null when unlicensed/invalid. */
|
|
209
|
+
export function getLicenseClaims(): LicenseClaims | null {
|
|
210
|
+
return runState?.status === 'valid' ? runState.claims ?? null : null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The current license error message for this run, if any. */
|
|
214
|
+
export function getLicenseError(): string | null {
|
|
215
|
+
return runState?.status === 'invalid' ? runState.message ?? 'Invalid license.' : null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface EnsureLicenseOptions {
|
|
219
|
+
projectDir?: string;
|
|
220
|
+
server?: string;
|
|
221
|
+
env?: NodeJS.ProcessEnv;
|
|
222
|
+
fetchImpl?: typeof fetch;
|
|
223
|
+
/** Force a JWKS re-fetch (ignore the memory cache). */
|
|
224
|
+
force?: boolean;
|
|
109
225
|
}
|
|
110
226
|
|
|
111
227
|
/**
|
|
112
|
-
*
|
|
113
|
-
*
|
|
228
|
+
* Operator escape hatch for the license authority itself. The authority issues
|
|
229
|
+
* licenses and cannot verify a token against itself at boot (its HTTP server is
|
|
230
|
+
* not listening yet), so it runs with `NEXUS_LICENSE_ISSUER=1`. This is a
|
|
231
|
+
* deployment flag — end-user projects should NOT set it.
|
|
114
232
|
*/
|
|
115
|
-
export function
|
|
116
|
-
|
|
117
|
-
return Buffer.from(hkdfSync('sha256', masterKey, HKDF_SALT, packageName, HKDF_LENGTH)).toString('hex');
|
|
233
|
+
export function isLicenseIssuer(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
234
|
+
return (env.NEXUS_LICENSE_ISSUER ?? '').trim() === '1';
|
|
118
235
|
}
|
|
119
236
|
|
|
120
|
-
/**
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
237
|
+
/**
|
|
238
|
+
* Verify the license once for this run: signature (via fetched JWKS) + online
|
|
239
|
+
* status with the authority. Caches the result in memory. Throws LicenseError
|
|
240
|
+
* when missing/invalid/revoked/unreachable (fail-closed).
|
|
241
|
+
*/
|
|
242
|
+
export async function ensureLicense(opts: EnsureLicenseOptions = {}): Promise<LicenseClaims> {
|
|
243
|
+
const env = opts.env ?? process.env;
|
|
244
|
+
if (isLicenseIssuer(env)) {
|
|
245
|
+
const claims: LicenseClaims = { iss: LICENSE_ISSUER, plan: 'issuer' };
|
|
246
|
+
runState = { status: 'valid', claims, checkedAt: Date.now() };
|
|
247
|
+
return claims;
|
|
248
|
+
}
|
|
249
|
+
const resolved = resolveLicenseToken({ projectDir: opts.projectDir, env });
|
|
250
|
+
if (!resolved) throw new LicenseError('nexus');
|
|
251
|
+
|
|
252
|
+
const server = (opts.server ?? getLicenseServer(env)).replace(/\/+$/, '');
|
|
253
|
+
|
|
254
|
+
let claims: LicenseClaims;
|
|
255
|
+
try {
|
|
256
|
+
const jwks = await fetchLicenseJwks(server, opts.fetchImpl, opts.force);
|
|
257
|
+
claims = await verifyLicenseToken(resolved.token, jwks);
|
|
258
|
+
} catch (err) {
|
|
259
|
+
const message = `Invalid license token: ${(err as Error).message}`;
|
|
260
|
+
runState = { status: 'invalid', message, checkedAt: Date.now() };
|
|
261
|
+
throw new LicenseError('nexus', `${message} Run \`nexus license add\` to get a valid one.`);
|
|
262
|
+
}
|
|
124
263
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
264
|
+
let online: VerifyResult;
|
|
265
|
+
try {
|
|
266
|
+
online = await verifyLicenseOnline(server, resolved.token, opts.fetchImpl);
|
|
267
|
+
} catch (err) {
|
|
268
|
+
const message = `Could not verify license with ${server}: ${(err as Error).message}`;
|
|
269
|
+
runState = { status: 'invalid', message, checkedAt: Date.now() };
|
|
270
|
+
throw new LicenseError('nexus', message);
|
|
271
|
+
}
|
|
272
|
+
if (!online.valid) {
|
|
273
|
+
const reason = (online.message ?? 'invalid key').replace(/\.\s*$/, '');
|
|
274
|
+
const message = `License rejected by ${server}: ${reason}.`;
|
|
275
|
+
runState = { status: 'invalid', message, checkedAt: Date.now() };
|
|
276
|
+
throw new LicenseError('nexus', `${message} Run \`nexus license add\` with a valid license.`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
runState = { status: 'valid', claims, checkedAt: Date.now() };
|
|
280
|
+
return claims;
|
|
131
281
|
}
|
|
132
282
|
|
|
133
283
|
/**
|
|
134
|
-
* Gate for licensed features.
|
|
135
|
-
*
|
|
284
|
+
* Gate for licensed features. Sync — reads the in-memory result set by
|
|
285
|
+
* `ensureLicense()` at boot. Throws LicenseError when not licensed.
|
|
136
286
|
*/
|
|
137
|
-
export function requireLicense(packageName: string
|
|
138
|
-
|
|
139
|
-
if (
|
|
140
|
-
|
|
287
|
+
export function requireLicense(packageName: string): void {
|
|
288
|
+
if (isLicenseIssuer()) return;
|
|
289
|
+
if (runState?.status === 'valid') return;
|
|
290
|
+
throw new LicenseError(packageName, runState?.message ? `${runState.message} Run \`nexus license add\`.` : undefined);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Test/tooling helper — clear the in-memory run state. */
|
|
294
|
+
export function resetLicenseState(): void {
|
|
295
|
+
runState = null;
|
|
296
|
+
jwksCache = null;
|
|
141
297
|
}
|
package/tests/license.test.ts
CHANGED
|
@@ -1,97 +1,185 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import {
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
2
|
+
import { mkdtempSync, writeFileSync, rmSync, readFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { generateKeyPair, exportJWK, SignJWT, type KeyLike } from 'jose';
|
|
3
6
|
import {
|
|
4
|
-
|
|
7
|
+
ensureLicense,
|
|
5
8
|
requireLicense,
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
+
resolveLicenseToken,
|
|
10
|
+
storeLicenseToken,
|
|
11
|
+
removeLicense,
|
|
12
|
+
verifyLicenseToken,
|
|
13
|
+
fetchLicenseJwks,
|
|
14
|
+
resetLicenseState,
|
|
15
|
+
licenseFilePath,
|
|
16
|
+
getLicenseServer,
|
|
9
17
|
LicenseError,
|
|
10
18
|
LICENSE_ENV_VAR,
|
|
19
|
+
LICENSE_SERVER_ENV_VAR,
|
|
20
|
+
LICENSE_ISSUER,
|
|
11
21
|
} from '../src/license.js';
|
|
12
22
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
const SERVER = 'http://auth.test';
|
|
24
|
+
const NO_HOME = '/nonexistent-home-xyz';
|
|
25
|
+
|
|
26
|
+
let privateKey: KeyLike;
|
|
27
|
+
let publicJwk: Record<string, unknown>;
|
|
28
|
+
|
|
29
|
+
async function signToken(opts: { key?: KeyLike; kid?: string; expires?: string } = {}): Promise<string> {
|
|
30
|
+
const key = opts.key ?? privateKey;
|
|
31
|
+
return new SignJWT({ plan: 'pro', seats: 1 })
|
|
32
|
+
.setProtectedHeader({ alg: 'EdDSA', kid: opts.kid ?? 'k1' })
|
|
33
|
+
.setIssuer(LICENSE_ISSUER)
|
|
34
|
+
.setIssuedAt()
|
|
35
|
+
.setExpirationTime(opts.expires ?? '1h')
|
|
36
|
+
.sign(key);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Stub fetch: JWKS endpoint + online verify (overridable). */
|
|
40
|
+
function stubFetch(opts: { online?: unknown } = {}): typeof fetch {
|
|
41
|
+
return (async (url: string) => {
|
|
42
|
+
if (url.includes('/.well-known/nexus-license-jwks.json')) {
|
|
43
|
+
return { ok: true, status: 200, json: async () => ({ keys: [publicJwk] }) } as unknown as Response;
|
|
44
|
+
}
|
|
45
|
+
if (url.includes('/api/license-verify')) {
|
|
46
|
+
return { ok: true, status: 200, json: async () => opts.online ?? { valid: true, plan: 'pro' } } as unknown as Response;
|
|
47
|
+
}
|
|
48
|
+
return { ok: false, status: 404, json: async () => ({}) } as unknown as Response;
|
|
49
|
+
}) as unknown as typeof fetch;
|
|
50
|
+
}
|
|
24
51
|
|
|
25
|
-
|
|
26
|
-
const
|
|
52
|
+
function withTokenFile(token: string): string {
|
|
53
|
+
const dir = mkdtempSync(join(tmpdir(), 'nexus-lic-'));
|
|
54
|
+
storeLicenseToken(dir, token);
|
|
55
|
+
return dir;
|
|
56
|
+
}
|
|
27
57
|
|
|
28
|
-
|
|
29
|
-
|
|
58
|
+
beforeAll(async () => {
|
|
59
|
+
const pair = await generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true });
|
|
60
|
+
privateKey = pair.privateKey;
|
|
61
|
+
publicJwk = { ...(await exportJWK(pair.publicKey)), kid: 'k1', alg: 'EdDSA', use: 'sig' };
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('resolveLicenseToken / storage', () => {
|
|
65
|
+
it('prefers the env token', () => {
|
|
66
|
+
expect(resolveLicenseToken({ env: { [LICENSE_ENV_VAR]: 'env-token' } as NodeJS.ProcessEnv })?.source).toBe('env');
|
|
30
67
|
});
|
|
31
68
|
|
|
32
|
-
it('
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
69
|
+
it('reads the project license file', () => {
|
|
70
|
+
const dir = withTokenFile('file-token');
|
|
71
|
+
try {
|
|
72
|
+
const r = resolveLicenseToken({ projectDir: dir, env: {} as NodeJS.ProcessEnv });
|
|
73
|
+
expect(r?.token).toBe('file-token');
|
|
74
|
+
expect(r?.source).toBe('file');
|
|
75
|
+
} finally {
|
|
76
|
+
rmSync(dir, { recursive: true, force: true });
|
|
77
|
+
}
|
|
38
78
|
});
|
|
39
79
|
|
|
40
|
-
it('
|
|
41
|
-
|
|
80
|
+
it('stores and removes the project license', () => {
|
|
81
|
+
const dir = mkdtempSync(join(tmpdir(), 'nexus-lic-'));
|
|
42
82
|
try {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
expect((
|
|
83
|
+
storeLicenseToken(dir, 'tok');
|
|
84
|
+
expect(existsSync(licenseFilePath(dir))).toBe(true);
|
|
85
|
+
expect(readFileSync(licenseFilePath(dir), 'utf8').trim()).toBe('tok');
|
|
86
|
+
expect(removeLicense(dir)).toBe(true);
|
|
87
|
+
expect(existsSync(licenseFilePath(dir))).toBe(false);
|
|
88
|
+
} finally {
|
|
89
|
+
rmSync(dir, { recursive: true, force: true });
|
|
46
90
|
}
|
|
47
91
|
});
|
|
48
92
|
});
|
|
49
93
|
|
|
50
|
-
describe('
|
|
51
|
-
it('
|
|
52
|
-
|
|
94
|
+
describe('ensureLicense (signed token + online)', () => {
|
|
95
|
+
it('accepts a valid token and marks the run licensed', async () => {
|
|
96
|
+
resetLicenseState();
|
|
97
|
+
const dir = withTokenFile(await signToken());
|
|
98
|
+
try {
|
|
99
|
+
const claims = await ensureLicense({ projectDir: dir, server: SERVER, fetchImpl: stubFetch(), force: true });
|
|
100
|
+
expect(claims.plan).toBe('pro');
|
|
101
|
+
expect(() => requireLicense('nexus-ai-client')).not.toThrow();
|
|
102
|
+
} finally {
|
|
103
|
+
rmSync(dir, { recursive: true, force: true });
|
|
104
|
+
resetLicenseState();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('rejects a forged token (wrong signing key)', async () => {
|
|
109
|
+
resetLicenseState();
|
|
110
|
+
const other = await generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true });
|
|
111
|
+
const dir = withTokenFile(await signToken({ key: other.privateKey }));
|
|
112
|
+
try {
|
|
113
|
+
await expect(ensureLicense({ projectDir: dir, server: SERVER, fetchImpl: stubFetch(), force: true })).rejects.toThrowError(LicenseError);
|
|
114
|
+
expect(() => requireLicense('nexus-ai-client')).toThrowError(/Invalid license/i);
|
|
115
|
+
} finally {
|
|
116
|
+
rmSync(dir, { recursive: true, force: true });
|
|
117
|
+
resetLicenseState();
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('rejects an expired token', async () => {
|
|
122
|
+
resetLicenseState();
|
|
123
|
+
const dir = withTokenFile(await signToken({ expires: '-1h' }));
|
|
124
|
+
try {
|
|
125
|
+
await expect(ensureLicense({ projectDir: dir, server: SERVER, fetchImpl: stubFetch(), force: true })).rejects.toThrowError(LicenseError);
|
|
126
|
+
} finally {
|
|
127
|
+
rmSync(dir, { recursive: true, force: true });
|
|
128
|
+
resetLicenseState();
|
|
129
|
+
}
|
|
53
130
|
});
|
|
54
131
|
|
|
55
|
-
it('
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
132
|
+
it('rejects a raw (non-JWS) env value', async () => {
|
|
133
|
+
resetLicenseState();
|
|
134
|
+
await expect(
|
|
135
|
+
ensureLicense({ server: SERVER, env: { [LICENSE_ENV_VAR]: 'K8NK-M4SA-ECU4-400K' } as NodeJS.ProcessEnv, fetchImpl: stubFetch(), force: true }),
|
|
136
|
+
).rejects.toThrowError(LicenseError);
|
|
59
137
|
});
|
|
60
138
|
|
|
61
|
-
it('
|
|
62
|
-
|
|
63
|
-
|
|
139
|
+
it('rejects when the authority says invalid (revoked)', async () => {
|
|
140
|
+
resetLicenseState();
|
|
141
|
+
const dir = withTokenFile(await signToken());
|
|
64
142
|
try {
|
|
65
|
-
expect(
|
|
66
|
-
|
|
67
|
-
);
|
|
143
|
+
await expect(
|
|
144
|
+
ensureLicense({ projectDir: dir, server: SERVER, fetchImpl: stubFetch({ online: { valid: false, message: 'License has been revoked.' } }), force: true }),
|
|
145
|
+
).rejects.toThrowError(/rejected/i);
|
|
68
146
|
} finally {
|
|
69
|
-
|
|
147
|
+
rmSync(dir, { recursive: true, force: true });
|
|
148
|
+
resetLicenseState();
|
|
70
149
|
}
|
|
71
150
|
});
|
|
72
151
|
|
|
73
|
-
it('
|
|
74
|
-
|
|
75
|
-
|
|
152
|
+
it('fails closed when the authority is unreachable', async () => {
|
|
153
|
+
resetLicenseState();
|
|
154
|
+
const dir = withTokenFile(await signToken());
|
|
155
|
+
const downFetch = (async (url: string) => {
|
|
156
|
+
if (url.includes('jwks')) return { ok: true, status: 200, json: async () => ({ keys: [publicJwk] }) } as unknown as Response;
|
|
157
|
+
throw new Error('network down');
|
|
158
|
+
}) as unknown as typeof fetch;
|
|
76
159
|
try {
|
|
77
|
-
expect(
|
|
160
|
+
await expect(ensureLicense({ projectDir: dir, server: SERVER, fetchImpl: downFetch, force: true })).rejects.toThrowError(/Could not verify/i);
|
|
78
161
|
} finally {
|
|
79
|
-
|
|
80
|
-
|
|
162
|
+
rmSync(dir, { recursive: true, force: true });
|
|
163
|
+
resetLicenseState();
|
|
81
164
|
}
|
|
82
165
|
});
|
|
166
|
+
|
|
167
|
+
it('throws when no license is configured', async () => {
|
|
168
|
+
resetLicenseState();
|
|
169
|
+
await expect(ensureLicense({ projectDir: NO_HOME, env: {} as NodeJS.ProcessEnv, fetchImpl: stubFetch() })).rejects.toThrowError(LicenseError);
|
|
170
|
+
});
|
|
83
171
|
});
|
|
84
172
|
|
|
85
|
-
describe('
|
|
86
|
-
it('
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
expect(
|
|
173
|
+
describe('token primitives', () => {
|
|
174
|
+
it('verifies a token signature against the JWKS', async () => {
|
|
175
|
+
const jwks = await fetchLicenseJwks(SERVER, stubFetch(), true);
|
|
176
|
+
const claims = await verifyLicenseToken(await signToken(), jwks);
|
|
177
|
+
expect(claims.iss).toBe(LICENSE_ISSUER);
|
|
178
|
+
expect(claims.plan).toBe('pro');
|
|
90
179
|
});
|
|
91
180
|
|
|
92
|
-
it('
|
|
93
|
-
expect(
|
|
94
|
-
expect(
|
|
95
|
-
expect(keysEqual('abc', 'abcd')).toBe(false);
|
|
181
|
+
it('honours the NEXUS_LICENSE_SERVER env override', () => {
|
|
182
|
+
expect(getLicenseServer({ [LICENSE_SERVER_ENV_VAR]: 'http://custom:9/' } as NodeJS.ProcessEnv)).toBe('http://custom:9');
|
|
183
|
+
expect(getLicenseServer({} as NodeJS.ProcessEnv)).toContain('nexus.bhooai.com');
|
|
96
184
|
});
|
|
97
185
|
});
|