@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 CHANGED
@@ -1,17 +1,30 @@
1
1
  {
2
2
  "name": "@bhooai/nexus-crypto",
3
- "version": "2.0.15",
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 — one master key, per-package derived keys.
2
+ * License core — authority-signed tokens, verified online each run.
3
3
  *
4
- * Flow: the user pastes a master key (`nxl_…`, issued by nexus.bhooai.com)
5
- * once via `nexus license add`. Every package derives its own key from that
6
- * single master secret with HKDF-SHA256 (salt `bhooai-nexus/v1`, info =
7
- * package name), and gated features call `requireLicense()` which throws a
8
- * "add license first" error when no master key is present.
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
- * Master key lookup order: `NEXUS_LICENSE_KEY` env → `~/.nexus/license.json`
11
- * `./.nexus-license.json` (project-local).
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 { hkdfSync, timingSafeEqual } from 'node:crypto';
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 { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, rmSync } from 'node:fs';
24
+ import { createLocalJWKSet, jwtVerify, type JSONWebKeySet } from 'jose';
17
25
 
18
26
  export const LICENSE_ENV_VAR = 'NEXUS_LICENSE_KEY';
19
- export const LICENSE_FILE_NAME = 'license.json';
20
- export const LICENSE_DIR_NAME = '.nexus';
21
- export const PROJECT_LICENSE_FILE = '.nexus-license.json';
22
- export const HKDF_SALT = 'bhooai-nexus/v1';
23
- export const HKDF_LENGTH = 32;
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 = `No license found — run \`nexus license add <key>\` (get one at https://nexus.bhooai.com/account) and retry.`,
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 interface StoredLicense {
38
- key: string;
39
- addedAt: string;
40
- server?: string;
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
- function licenseFileCandidates(projectDir?: string): string[] {
44
- const files: string[] = [];
45
- if (projectDir) files.push(join(projectDir, PROJECT_LICENSE_FILE));
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
- /** Read the stored master key file, or null when missing/unparseable. */
55
- export function readLicenseFile(path: string): StoredLicense | null {
56
- try {
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
- * Resolve the master license key without touching the network.
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
- for (const file of licenseFileCandidates(opts.projectDir)) {
75
- const stored = readLicenseFile(file);
76
- if (stored) return stored.key;
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
- /** Persist a master key to `~/.nexus/license.json` (mode 0600). */
82
- export function saveMasterKey(key: string, opts: { server?: string } = {}): string {
83
- const dir = join(homedir(), LICENSE_DIR_NAME);
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 path = join(dir, LICENSE_FILE_NAME);
86
- writeFileSync(
87
- path,
88
- JSON.stringify({ key, addedAt: new Date().toISOString(), ...(opts.server ? { server: opts.server } : {}) }, null, 2),
89
- 'utf8',
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
- chmodSync(path, 0o600);
152
+ res = await fetchImpl(url, { headers: { accept: 'application/json' } });
93
153
  } catch {
94
- // Windows ACLsbest effort
154
+ throw new Error(`Cannot reach the license authority at ${base} check your connection.`);
95
155
  }
96
- return path;
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
- /** Remove the user-wide license file. Returns true when a file was removed. */
100
- export function removeMasterKey(): boolean {
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
- const path = join(homedir(), LICENSE_DIR_NAME, LICENSE_FILE_NAME);
103
- if (!existsSync(path)) return false;
104
- rmSync(path);
105
- return true;
185
+ res = await fetchImpl(url, {
186
+ method: 'POST',
187
+ headers: { 'Content-Type': 'application/json' },
188
+ body: JSON.stringify({ token }),
189
+ });
106
190
  } catch {
107
- return false;
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
- * Derive a package-specific key from the master secret (HKDF-SHA256).
113
- * Deterministic and one-way: packages never see each other's keys or the master.
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 derivePackageKey(masterKey: string, packageName: string): string {
116
- if (!masterKey) throw new LicenseError(packageName);
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
- /** Fingerprint for display (first 12 chars) — safe to log, never the key. */
121
- export function keyFingerprint(key: string): string {
122
- return key.slice(0, 12);
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
- /** Constant-time master-key comparison (for tests/tools, not for secret storage). */
126
- export function keysEqual(a: string, b: string): boolean {
127
- const ab = Buffer.from(a);
128
- const bb = Buffer.from(b);
129
- if (ab.length !== bb.length) return false;
130
- return timingSafeEqual(ab, bb);
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. Returns the derived package key, or throws a
135
- * "add license first" LicenseError when no master key is configured.
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, opts: { projectDir?: string } = {}): string {
138
- const master = loadMasterKey({ projectDir: opts.projectDir });
139
- if (!master) throw new LicenseError(packageName);
140
- return derivePackageKey(master, packageName);
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
  }
@@ -1,97 +1,185 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { hkdfSync } from 'node:crypto';
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
- derivePackageKey,
7
+ ensureLicense,
5
8
  requireLicense,
6
- loadMasterKey,
7
- keyFingerprint,
8
- keysEqual,
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
- describe('hkdf primitive (RFC 5869 test case 1, SHA-256)', () => {
14
- it('matches the RFC vector', () => {
15
- const ikm = Buffer.alloc(22, 0x0b);
16
- const salt = Buffer.from('000102030405060708090a0b0c', 'hex');
17
- const info = Buffer.from('f0f1f2f3f4f5f6f7f8f9', 'hex');
18
- const okm = Buffer.from(hkdfSync('sha256', ikm, salt, info, 42)).toString('hex');
19
- expect(okm).toBe(
20
- '3cb25f25faacd57a90434f64d0362f2a' + '2d2d0a90cf1a5a4c5db02d56ecc4c5bf' + '34007208d5b887185865',
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
- describe('derivePackageKey', () => {
26
- const master = 'nxl_test_master_key_001';
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
- it('is deterministic', () => {
29
- expect(derivePackageKey(master, 'nexus-payments')).toBe(derivePackageKey(master, 'nexus-payments'));
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('isolates packages (32-byte hex each)', () => {
33
- const a = derivePackageKey(master, 'nexus-payments');
34
- const b = derivePackageKey(master, 'nexus-ai-client');
35
- expect(a).toHaveLength(64);
36
- expect(b).toHaveLength(64);
37
- expect(a).not.toBe(b);
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('throws LicenseError on empty master', () => {
41
- expect(() => derivePackageKey('', 'nexus-payments')).toThrowError(LicenseError);
80
+ it('stores and removes the project license', () => {
81
+ const dir = mkdtempSync(join(tmpdir(), 'nexus-lic-'));
42
82
  try {
43
- derivePackageKey('', 'nexus-payments');
44
- } catch (e) {
45
- expect((e as Error).message).toContain('nexus license add');
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('loadMasterKey / requireLicense', () => {
51
- it('prefers the env var', () => {
52
- expect(loadMasterKey({ env: { [LICENSE_ENV_VAR]: 'nxl_from_env' } as NodeJS.ProcessEnv })).toBe('nxl_from_env');
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('returns null when nothing is configured', () => {
56
- expect(
57
- loadMasterKey({ env: {} as NodeJS.ProcessEnv, projectDir: '/nonexistent-dir-xyz' }),
58
- ).toBeNull();
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('requireLicense throws the add-first error without a key', () => {
62
- const prev = process.env[LICENSE_ENV_VAR];
63
- delete process.env[LICENSE_ENV_VAR];
139
+ it('rejects when the authority says invalid (revoked)', async () => {
140
+ resetLicenseState();
141
+ const dir = withTokenFile(await signToken());
64
142
  try {
65
- expect(() => requireLicense('nexus-payments', { projectDir: '/nonexistent-dir-xyz' })).toThrowError(
66
- /nexus license add/,
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
- if (prev !== undefined) process.env[LICENSE_ENV_VAR] = prev;
147
+ rmSync(dir, { recursive: true, force: true });
148
+ resetLicenseState();
70
149
  }
71
150
  });
72
151
 
73
- it('requireLicense derives from the env key', () => {
74
- const prev = process.env[LICENSE_ENV_VAR];
75
- process.env[LICENSE_ENV_VAR] = 'nxl_env_master';
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(requireLicense('nexus-payments')).toBe(derivePackageKey('nxl_env_master', 'nexus-payments'));
160
+ await expect(ensureLicense({ projectDir: dir, server: SERVER, fetchImpl: downFetch, force: true })).rejects.toThrowError(/Could not verify/i);
78
161
  } finally {
79
- if (prev !== undefined) process.env[LICENSE_ENV_VAR] = prev;
80
- else delete process.env[LICENSE_ENV_VAR];
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('helpers', () => {
86
- it('fingerprints without leaking the key', () => {
87
- const fp = keyFingerprint('nxl_abcdefghijklmnop');
88
- expect(fp).toHaveLength(12);
89
- expect('nxl_abcdefghijklmnop').toContain(fp);
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('compares in constant time', () => {
93
- expect(keysEqual('abc', 'abc')).toBe(true);
94
- expect(keysEqual('abc', 'abd')).toBe(false);
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
  });