@buildproven/license-core 1.0.1 → 1.1.0

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/README.md CHANGED
@@ -153,6 +153,105 @@ openssl rsa -in private.pem -pubout -out public.pem
153
153
 
154
154
  The package never handles key generation, storage, or rotation — that's your call. Use whatever secret manager you already have.
155
155
 
156
+ ### Rotating a signing key
157
+
158
+ Every entry in the registry has a `keyId` field, and `_metadata.keyId` records which key signed the registry as a whole. To rotate without breaking already-shipped clients:
159
+
160
+ 1. Generate a new keypair. Add the new private key to your fulfillment env vars (e.g. `LICENSE_PRIVATE_KEY_V2`).
161
+ 2. Update fulfillment to sign new entries with the new key, passing `keyId: 'v2'`:
162
+
163
+ ```ts
164
+ buildSignedRegistry(entries, NEW_PRIVATE_KEY, 'v2');
165
+ ```
166
+
167
+ 3. **Bundle BOTH public keys with your client.** When verifying, look up the right key based on `entry.keyId`:
168
+
169
+ ```ts
170
+ const publicKeys = { default: OLD_PUBLIC_KEY, v2: NEW_PUBLIC_KEY };
171
+ const result = validateRegistryEntry({
172
+ licenseKey,
173
+ entry,
174
+ publicKeyPem: publicKeys[entry.keyId] ?? publicKeys.default,
175
+ });
176
+ ```
177
+
178
+ 4. Existing entries (signed with `keyId: 'default'`) continue to verify under the old public key. New entries verify under the new key.
179
+ 5. Once all old entries have expired or been re-issued, you can ship a client release that drops the old public key.
180
+
181
+ The package leaves `keyId` as a free-form string, so use whatever convention you like (`v1`/`v2`, dates, fingerprints).
182
+
183
+ ## Recurring billing & subscriptions
184
+
185
+ The frozen-contract v1.x API doesn't include `expiresAt` on payloads, so subscriptions need a layer above the package. Two patterns work:
186
+
187
+ ### Pattern A — short-lived registries (re-sign on a schedule)
188
+
189
+ Your fulfillment service holds the source-of-truth subscription state (Stripe, paddle, whatever). On a cron/interval (e.g. once an hour), it walks active customers, builds a fresh `Registry`, calls `buildSignedRegistry`, and serves the result. Cancelled customers simply stop appearing in the next signed registry.
190
+
191
+ The client refreshes the registry once per launch + once per N hours/days, and falls back to the locally-cached signed JSON if offline. A 7-day grace period (registry valid 7 days offline) is typical — long enough that flaky internet doesn't lock paying customers out, short enough that cancellations propagate within a week.
192
+
193
+ ```ts
194
+ // Client side
195
+ async function getRegistry() {
196
+ try {
197
+ const res = await fetch(REGISTRY_URL, { signal: AbortSignal.timeout(5000) });
198
+ const signed = await res.json();
199
+ const entries = verifyRegistryMetadata(signed, PUBLIC_KEY);
200
+ await fs.writeFile(CACHE_PATH, JSON.stringify(signed)); // cache for offline
201
+ return entries;
202
+ } catch {
203
+ const cached = JSON.parse(await fs.readFile(CACHE_PATH, 'utf8'));
204
+ return verifyRegistryMetadata(cached, PUBLIC_KEY); // offline path
205
+ }
206
+ }
207
+ ```
208
+
209
+ ### Pattern B — long-lived entries + revocation list
210
+
211
+ Entries are signed once and stay valid forever. A separate signed `revoked.json` lists keys that should no longer verify. The client fetches both and rejects any license that appears in the revocations file.
212
+
213
+ This is faster on the fulfillment side (you don't re-sign the whole registry every hour) but requires more client logic. Tracked in the v2.x roadmap because v1.x doesn't ship a revocation helper yet.
214
+
215
+ ### Which pattern when?
216
+
217
+ - **Few customers, frequent cancellations:** Pattern A — re-signing daily is cheap.
218
+ - **Many customers, rare cancellations:** Pattern B — appending to a small revocations list is cheaper than re-signing thousands of entries.
219
+ - **Lifetime licenses only:** neither — sign once on purchase, never re-sign.
220
+
221
+ ## Publishing your own fork (Trusted Publishing setup)
222
+
223
+ If you fork this and want to publish under your own scope via npm Trusted Publishing (no `NPM_TOKEN`), there's one gotcha worth documenting because it cost an hour during the initial release here:
224
+
225
+ **Don't pass `registry-url` to `actions/setup-node`.** It auto-generates an `.npmrc` with a placeholder `${NODE_AUTH_TOKEN}` value, which makes `npm publish` authenticate via that fake token instead of falling through to OIDC. Result: a `404 Not Found` from the registry that looks like a misconfigured trusted publisher.
226
+
227
+ The minimal working workflow:
228
+
229
+ ```yaml
230
+ name: Publish to npm
231
+ on:
232
+ push:
233
+ tags: ['v*.*.*']
234
+
235
+ jobs:
236
+ publish:
237
+ runs-on: ubuntu-latest
238
+ permissions:
239
+ contents: read
240
+ id-token: write # required for OIDC
241
+ steps:
242
+ - uses: actions/checkout@v4
243
+ - uses: actions/setup-node@v4
244
+ with:
245
+ node-version: '22'
246
+ # NO registry-url here — it would inject a fake NODE_AUTH_TOKEN
247
+ - run: npm install -g npm@latest # need >=11.5.1 for Trusted Publishing
248
+ - run: npm ci
249
+ - run: npm test
250
+ - run: npm publish --access public --provenance
251
+ ```
252
+
253
+ On the npm side, configure the trusted publisher under your package's settings page after the package exists (chicken-and-egg: do the _first_ publish via a granular access token, then switch to OIDC for everything after).
254
+
156
255
  ## License
157
256
 
158
257
  [MIT](./LICENSE) © Vibe Build Lab LLC
package/dist/index.cjs CHANGED
@@ -24,6 +24,7 @@ __export(index_exports, {
24
24
  buildSignedRegistry: () => buildSignedRegistry,
25
25
  computeHash: () => computeHash,
26
26
  hashEmail: () => hashEmail,
27
+ isDeveloperMode: () => isDeveloperMode,
27
28
  isValidLicenseKey: () => isValidLicenseKey,
28
29
  licenseKeyPattern: () => licenseKeyPattern,
29
30
  normalizeEmail: () => normalizeEmail,
@@ -186,17 +187,36 @@ function licenseKeyPattern(prefix) {
186
187
  return new RegExp(`^${prefix}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$`);
187
188
  }
188
189
  function isValidLicenseKey(key, prefix) {
190
+ if (typeof key !== "string") return false;
189
191
  return licenseKeyPattern(prefix).test(key.trim().toUpperCase());
190
192
  }
191
193
  function normalizeLicenseKey(key) {
194
+ if (typeof key !== "string") return "";
192
195
  return key.trim().toUpperCase();
193
196
  }
197
+
198
+ // src/developer-mode.ts
199
+ var import_fs = require("fs");
200
+ function isDeveloperMode(config) {
201
+ if (process.env.NODE_ENV === "production") return false;
202
+ if (process.env[config.envVar] === "true") return true;
203
+ try {
204
+ if ((0, import_fs.existsSync)(config.markerFile)) return true;
205
+ } catch (err) {
206
+ const code = err?.code;
207
+ if (code === "ELOOP" && process.env.NODE_ENV === "production") {
208
+ throw new Error("Symlink loop detected in license marker path \u2014 possible tampering");
209
+ }
210
+ }
211
+ return false;
212
+ }
194
213
  // Annotate the CommonJS export names for ESM import in node:
195
214
  0 && (module.exports = {
196
215
  buildLicensePayload,
197
216
  buildSignedRegistry,
198
217
  computeHash,
199
218
  hashEmail,
219
+ isDeveloperMode,
200
220
  isValidLicenseKey,
201
221
  licenseKeyPattern,
202
222
  normalizeEmail,
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/signing.ts","../src/payload.ts","../src/registry.ts","../src/validator.ts","../src/key-format.ts"],"sourcesContent":["// Crypto primitives\nexport {\n stableStringify,\n signPayload,\n verifyPayload,\n computeHash,\n timingSafeStringEqual,\n} from './signing.js';\n\n// Payload construction\nexport { normalizeEmail, hashEmail, buildLicensePayload } from './payload.js';\n\n// Registry construction\nexport { buildSignedRegistry } from './registry.js';\n\n// Validation helpers (pure — no I/O)\nexport { validateRegistryEntry, verifyRegistryMetadata } from './validator.js';\nexport type { ValidatedEntry, ValidationFailure, ValidationResult } from './validator.js';\n\n// License key format\nexport { licenseKeyPattern, isValidLicenseKey, normalizeLicenseKey } from './key-format.js';\n\n// Types\nexport type {\n Tier,\n LicensePayload,\n RegistryEntry,\n Registry,\n RegistryMetadata,\n SignedRegistry,\n} from './types.js';\n","/**\n * Deterministic stringify + RSA-SHA256 sign/verify primitives.\n *\n * stableStringify must produce byte-identical output to QA Architect's\n * shipped lib/license-signing.js — the deployed CLI in customers' hands\n * uses that exact algorithm. Any divergence here breaks every QAA license\n * issued to date.\n */\n\nimport { sign as cryptoSign, verify as cryptoVerify, createHash } from 'crypto';\n\nexport function stableStringify(value: unknown, seen: WeakSet<object> = new WeakSet()): string {\n if (value === null || typeof value !== 'object') {\n return JSON.stringify(value);\n }\n if (seen.has(value as object)) {\n throw new Error('Circular reference detected in payload - cannot serialize');\n }\n seen.add(value as object);\n\n if (Array.isArray(value)) {\n return `[${value.map((item) => stableStringify(item, seen)).join(',')}]`;\n }\n const keys = Object.keys(value as Record<string, unknown>).sort();\n const entries = keys.map(\n (key) =>\n `${JSON.stringify(key)}:${stableStringify((value as Record<string, unknown>)[key], seen)}`,\n );\n return `{${entries.join(',')}}`;\n}\n\nexport function signPayload(payload: unknown, privateKeyPem: string): string {\n const data = Buffer.from(stableStringify(payload));\n return cryptoSign(null, data, privateKeyPem).toString('base64');\n}\n\nexport function verifyPayload(payload: unknown, signature: string, publicKeyPem: string): boolean {\n try {\n const data = Buffer.from(stableStringify(payload));\n return cryptoVerify(null, data, publicKeyPem, Buffer.from(signature, 'base64'));\n } catch {\n return false;\n }\n}\n\nexport function computeHash(data: string): string {\n return createHash('sha256').update(data).digest('hex');\n}\n\n/**\n * Constant-time string comparison. Same length precondition is checked\n * outside the comparison loop to avoid leaking length info.\n */\nexport function timingSafeStringEqual(a: string, b: string): boolean {\n if (a.length !== b.length) return false;\n let diff = 0;\n for (let i = 0; i < a.length; i++) {\n diff |= a.charCodeAt(i) ^ b.charCodeAt(i);\n }\n return diff === 0;\n}\n","/**\n * Email normalization, hashing, and license payload construction.\n *\n * buildLicensePayload is the contract the fulfillment service signs against\n * and that every client must rebuild bit-for-bit before verification. Adding\n * fields here = breaking change.\n */\n\nimport { createHash } from 'crypto';\nimport type { LicensePayload, Tier } from './types.js';\n\nexport function normalizeEmail(email: string): string | null {\n if (!email || typeof email !== 'string') return null;\n const normalized = email.trim().toLowerCase();\n if (!/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(normalized)) return null;\n return normalized.length > 0 ? normalized : null;\n}\n\nexport function hashEmail(email: string): string | null {\n const normalized = normalizeEmail(email);\n if (!normalized) return null;\n return createHash('sha256').update(normalized).digest('hex');\n}\n\nexport function buildLicensePayload(opts: {\n licenseKey: string;\n tier: Tier;\n isFounder: boolean;\n issued: string;\n emailHash?: string | null;\n}): LicensePayload {\n if (!opts.licenseKey || typeof opts.licenseKey !== 'string') {\n throw new Error('licenseKey is required and must be a string');\n }\n if (!opts.tier || typeof opts.tier !== 'string') {\n throw new Error('tier is required and must be a string');\n }\n if (!opts.issued || typeof opts.issued !== 'string') {\n throw new Error('issued is required and must be a string');\n }\n\n const payload: LicensePayload = {\n licenseKey: opts.licenseKey,\n tier: opts.tier,\n isFounder: Boolean(opts.isFounder),\n issued: opts.issued,\n };\n if (opts.emailHash) {\n payload.emailHash = opts.emailHash;\n }\n return payload;\n}\n","/**\n * Build a complete signed registry from a flat entries map.\n *\n * The registry signature covers ONLY the entries — _metadata is excluded.\n * QAA's deployed validator destructures `_metadata` out before verifying,\n * so any change to what's signed will break compatibility.\n */\n\nimport { computeHash, signPayload, stableStringify } from './signing.js';\nimport type { Registry, SignedRegistry } from './types.js';\n\nexport function buildSignedRegistry(\n entries: Registry,\n privateKeyPem: string,\n keyId = 'default',\n): SignedRegistry {\n const now = new Date().toISOString();\n const entriesStr = stableStringify(entries);\n const registrySignature = signPayload(entries, privateKeyPem);\n const hash = computeHash(entriesStr);\n\n return {\n _metadata: {\n version: '1.0',\n created: now,\n lastUpdate: now,\n description: 'License registry — populated by fulfillment webhook',\n algorithm: 'rsa-sha256',\n keyId,\n registrySignature,\n hash,\n totalLicenses: Object.keys(entries).length,\n },\n ...entries,\n };\n}\n","/**\n * Pure validation helpers — no I/O, no caching, no env.\n *\n * Both QA Architect's CLI and claude-kit-pro's MCP server use these\n * to verify a registry response. Anything that touches disk, network,\n * or process.env stays in the consuming product. This is the seam\n * that prevents the two validators from drifting apart.\n */\n\nimport { buildLicensePayload } from './payload.js';\nimport { computeHash, stableStringify, timingSafeStringEqual, verifyPayload } from './signing.js';\nimport type { RegistryEntry, SignedRegistry } from './types.js';\n\nexport interface ValidatedEntry {\n valid: true;\n tier: RegistryEntry['tier'];\n isFounder: boolean;\n customerId: string;\n keyId: string;\n}\n\nexport interface ValidationFailure {\n valid: false;\n error: string;\n}\n\nexport type ValidationResult = ValidatedEntry | ValidationFailure;\n\n/**\n * Verify a single registry entry against its embedded signature.\n * Optionally check the user's email hash against the entry's emailHash.\n *\n * Mirrors QAA's validateLicense() field-set exactly:\n * payload = { licenseKey, tier, isFounder, issued, emailHash? }\n */\nexport function validateRegistryEntry(opts: {\n licenseKey: string;\n entry: RegistryEntry;\n publicKeyPem: string;\n /** If supplied, must match entry.emailHash (timing-safe). */\n userEmailHash?: string;\n}): ValidationResult {\n const { licenseKey, entry, publicKeyPem, userEmailHash } = opts;\n\n if (entry.emailHash && userEmailHash && !timingSafeStringEqual(userEmailHash, entry.emailHash)) {\n return { valid: false, error: 'Email address does not match license registration' };\n }\n\n const payload = buildLicensePayload({\n licenseKey,\n tier: entry.tier,\n isFounder: entry.isFounder,\n issued: entry.issued,\n emailHash: entry.emailHash,\n });\n\n if (!verifyPayload(payload, entry.signature, publicKeyPem)) {\n return { valid: false, error: 'License entry signature verification failed' };\n }\n\n return {\n valid: true,\n tier: entry.tier,\n isFounder: entry.isFounder,\n customerId: entry.customerId,\n keyId: entry.keyId,\n };\n}\n\n/**\n * Verify a complete signed registry: registry-level signature + hash check.\n * Returns the entries map (with _metadata stripped) on success, throws on failure.\n *\n * Throws (rather than returning a result) because a registry signature failure\n * should halt validation entirely — clients should not fall back to entries\n * from an unverified registry.\n */\nexport function verifyRegistryMetadata(\n signedRegistry: SignedRegistry,\n publicKeyPem: string,\n): Record<string, RegistryEntry> {\n const { _metadata, ...entries } = signedRegistry;\n\n if (!_metadata?.registrySignature) {\n throw new Error('Registry missing _metadata.registrySignature');\n }\n\n if (!verifyPayload(entries, _metadata.registrySignature, publicKeyPem)) {\n throw new Error('Registry signature verification failed');\n }\n\n if (_metadata.hash) {\n const computed = computeHash(stableStringify(entries));\n if (!timingSafeStringEqual(computed, _metadata.hash)) {\n throw new Error('Registry hash mismatch');\n }\n }\n\n return entries as Record<string, RegistryEntry>;\n}\n","/**\n * Per-product license key format.\n *\n * QAA-XXXX-XXXX-XXXX-XXXX, CKIT-XXXX-XXXX-XXXX-XXXX, etc.\n * One factory so every product validates the same way.\n */\n\nexport function licenseKeyPattern(prefix: string): RegExp {\n if (!/^[A-Z0-9]+$/.test(prefix)) {\n throw new Error(`Prefix must be uppercase alphanumeric: ${prefix}`);\n }\n return new RegExp(`^${prefix}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$`);\n}\n\nexport function isValidLicenseKey(key: string, prefix: string): boolean {\n return licenseKeyPattern(prefix).test(key.trim().toUpperCase());\n}\n\nexport function normalizeLicenseKey(key: string): string {\n return key.trim().toUpperCase();\n}\n"],"mappings":"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","names":["cryptoSign","cryptoVerify","import_crypto"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/signing.ts","../src/payload.ts","../src/registry.ts","../src/validator.ts","../src/key-format.ts","../src/developer-mode.ts"],"sourcesContent":["// Crypto primitives\nexport {\n stableStringify,\n signPayload,\n verifyPayload,\n computeHash,\n timingSafeStringEqual,\n} from './signing.js';\n\n// Payload construction\nexport { normalizeEmail, hashEmail, buildLicensePayload } from './payload.js';\n\n// Registry construction\nexport { buildSignedRegistry } from './registry.js';\n\n// Validation helpers (pure — no I/O)\nexport { validateRegistryEntry, verifyRegistryMetadata } from './validator.js';\nexport type { ValidatedEntry, ValidationFailure, ValidationResult } from './validator.js';\n\n// License key format\nexport { licenseKeyPattern, isValidLicenseKey, normalizeLicenseKey } from './key-format.js';\n\n// Owner / developer mode (does filesystem + env I/O; one impl for every product)\nexport { isDeveloperMode } from './developer-mode.js';\nexport type { DeveloperModeConfig } from './developer-mode.js';\n\n// Types\nexport type {\n Tier,\n LicensePayload,\n RegistryEntry,\n Registry,\n RegistryMetadata,\n SignedRegistry,\n} from './types.js';\n","/**\n * Deterministic stringify + RSA-SHA256 sign/verify primitives.\n *\n * stableStringify must produce byte-identical output to QA Architect's\n * shipped lib/license-signing.js — the deployed CLI in customers' hands\n * uses that exact algorithm. Any divergence here breaks every QAA license\n * issued to date.\n */\n\nimport { sign as cryptoSign, verify as cryptoVerify, createHash } from 'crypto';\n\nexport function stableStringify(value: unknown, seen: WeakSet<object> = new WeakSet()): string {\n if (value === null || typeof value !== 'object') {\n return JSON.stringify(value);\n }\n if (seen.has(value as object)) {\n throw new Error('Circular reference detected in payload - cannot serialize');\n }\n seen.add(value as object);\n\n if (Array.isArray(value)) {\n return `[${value.map((item) => stableStringify(item, seen)).join(',')}]`;\n }\n const keys = Object.keys(value as Record<string, unknown>).sort();\n const entries = keys.map(\n (key) =>\n `${JSON.stringify(key)}:${stableStringify((value as Record<string, unknown>)[key], seen)}`,\n );\n return `{${entries.join(',')}}`;\n}\n\nexport function signPayload(payload: unknown, privateKeyPem: string): string {\n const data = Buffer.from(stableStringify(payload));\n return cryptoSign(null, data, privateKeyPem).toString('base64');\n}\n\nexport function verifyPayload(payload: unknown, signature: string, publicKeyPem: string): boolean {\n try {\n const data = Buffer.from(stableStringify(payload));\n return cryptoVerify(null, data, publicKeyPem, Buffer.from(signature, 'base64'));\n } catch {\n return false;\n }\n}\n\nexport function computeHash(data: string): string {\n return createHash('sha256').update(data).digest('hex');\n}\n\n/**\n * Constant-time string comparison. Same length precondition is checked\n * outside the comparison loop to avoid leaking length info.\n */\nexport function timingSafeStringEqual(a: string, b: string): boolean {\n if (a.length !== b.length) return false;\n let diff = 0;\n for (let i = 0; i < a.length; i++) {\n diff |= a.charCodeAt(i) ^ b.charCodeAt(i);\n }\n return diff === 0;\n}\n","/**\n * Email normalization, hashing, and license payload construction.\n *\n * buildLicensePayload is the contract the fulfillment service signs against\n * and that every client must rebuild bit-for-bit before verification. Adding\n * fields here = breaking change.\n */\n\nimport { createHash } from 'crypto';\nimport type { LicensePayload, Tier } from './types.js';\n\nexport function normalizeEmail(email: string): string | null {\n if (!email || typeof email !== 'string') return null;\n const normalized = email.trim().toLowerCase();\n if (!/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(normalized)) return null;\n return normalized.length > 0 ? normalized : null;\n}\n\nexport function hashEmail(email: string): string | null {\n const normalized = normalizeEmail(email);\n if (!normalized) return null;\n return createHash('sha256').update(normalized).digest('hex');\n}\n\nexport function buildLicensePayload(opts: {\n licenseKey: string;\n tier: Tier;\n isFounder: boolean;\n issued: string;\n emailHash?: string | null;\n}): LicensePayload {\n if (!opts.licenseKey || typeof opts.licenseKey !== 'string') {\n throw new Error('licenseKey is required and must be a string');\n }\n if (!opts.tier || typeof opts.tier !== 'string') {\n throw new Error('tier is required and must be a string');\n }\n if (!opts.issued || typeof opts.issued !== 'string') {\n throw new Error('issued is required and must be a string');\n }\n\n const payload: LicensePayload = {\n licenseKey: opts.licenseKey,\n tier: opts.tier,\n isFounder: Boolean(opts.isFounder),\n issued: opts.issued,\n };\n if (opts.emailHash) {\n payload.emailHash = opts.emailHash;\n }\n return payload;\n}\n","/**\n * Build a complete signed registry from a flat entries map.\n *\n * The registry signature covers ONLY the entries — _metadata is excluded.\n * QAA's deployed validator destructures `_metadata` out before verifying,\n * so any change to what's signed will break compatibility.\n */\n\nimport { computeHash, signPayload, stableStringify } from './signing.js';\nimport type { Registry, SignedRegistry } from './types.js';\n\nexport function buildSignedRegistry(\n entries: Registry,\n privateKeyPem: string,\n keyId = 'default',\n): SignedRegistry {\n const now = new Date().toISOString();\n const entriesStr = stableStringify(entries);\n const registrySignature = signPayload(entries, privateKeyPem);\n const hash = computeHash(entriesStr);\n\n return {\n _metadata: {\n version: '1.0',\n created: now,\n lastUpdate: now,\n description: 'License registry — populated by fulfillment webhook',\n algorithm: 'rsa-sha256',\n keyId,\n registrySignature,\n hash,\n totalLicenses: Object.keys(entries).length,\n },\n ...entries,\n };\n}\n","/**\n * Pure validation helpers — no I/O, no caching, no env.\n *\n * Both QA Architect's CLI and claude-kit-pro's MCP server use these\n * to verify a registry response. Anything that touches disk, network,\n * or process.env stays in the consuming product. This is the seam\n * that prevents the two validators from drifting apart.\n */\n\nimport { buildLicensePayload } from './payload.js';\nimport { computeHash, stableStringify, timingSafeStringEqual, verifyPayload } from './signing.js';\nimport type { RegistryEntry, SignedRegistry } from './types.js';\n\nexport interface ValidatedEntry {\n valid: true;\n tier: RegistryEntry['tier'];\n isFounder: boolean;\n customerId: string;\n keyId: string;\n}\n\nexport interface ValidationFailure {\n valid: false;\n error: string;\n}\n\nexport type ValidationResult = ValidatedEntry | ValidationFailure;\n\n/**\n * Verify a single registry entry against its embedded signature.\n * Optionally check the user's email hash against the entry's emailHash.\n *\n * Mirrors QAA's validateLicense() field-set exactly:\n * payload = { licenseKey, tier, isFounder, issued, emailHash? }\n */\nexport function validateRegistryEntry(opts: {\n licenseKey: string;\n entry: RegistryEntry;\n publicKeyPem: string;\n /** If supplied, must match entry.emailHash (timing-safe). */\n userEmailHash?: string;\n}): ValidationResult {\n const { licenseKey, entry, publicKeyPem, userEmailHash } = opts;\n\n if (entry.emailHash && userEmailHash && !timingSafeStringEqual(userEmailHash, entry.emailHash)) {\n return { valid: false, error: 'Email address does not match license registration' };\n }\n\n const payload = buildLicensePayload({\n licenseKey,\n tier: entry.tier,\n isFounder: entry.isFounder,\n issued: entry.issued,\n emailHash: entry.emailHash,\n });\n\n if (!verifyPayload(payload, entry.signature, publicKeyPem)) {\n return { valid: false, error: 'License entry signature verification failed' };\n }\n\n return {\n valid: true,\n tier: entry.tier,\n isFounder: entry.isFounder,\n customerId: entry.customerId,\n keyId: entry.keyId,\n };\n}\n\n/**\n * Verify a complete signed registry: registry-level signature + hash check.\n * Returns the entries map (with _metadata stripped) on success, throws on failure.\n *\n * Throws (rather than returning a result) because a registry signature failure\n * should halt validation entirely — clients should not fall back to entries\n * from an unverified registry.\n */\nexport function verifyRegistryMetadata(\n signedRegistry: SignedRegistry,\n publicKeyPem: string,\n): Record<string, RegistryEntry> {\n const { _metadata, ...entries } = signedRegistry;\n\n if (!_metadata?.registrySignature) {\n throw new Error('Registry missing _metadata.registrySignature');\n }\n\n if (!verifyPayload(entries, _metadata.registrySignature, publicKeyPem)) {\n throw new Error('Registry signature verification failed');\n }\n\n if (_metadata.hash) {\n const computed = computeHash(stableStringify(entries));\n if (!timingSafeStringEqual(computed, _metadata.hash)) {\n throw new Error('Registry hash mismatch');\n }\n }\n\n return entries as Record<string, RegistryEntry>;\n}\n","/**\n * Per-product license key format.\n *\n * QAA-XXXX-XXXX-XXXX-XXXX, CKIT-XXXX-XXXX-XXXX-XXXX, etc.\n * One factory so every product validates the same way.\n */\n\nexport function licenseKeyPattern(prefix: string): RegExp {\n if (!/^[A-Z0-9]+$/.test(prefix)) {\n throw new Error(`Prefix must be uppercase alphanumeric: ${prefix}`);\n }\n return new RegExp(`^${prefix}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$`);\n}\n\nexport function isValidLicenseKey(key: string, prefix: string): boolean {\n if (typeof key !== 'string') return false;\n return licenseKeyPattern(prefix).test(key.trim().toUpperCase());\n}\n\nexport function normalizeLicenseKey(key: string): string {\n // Defensive: callers pass keys straight from license files / env / CLI args,\n // any of which can be undefined or non-string for incomplete/corrupt input.\n // Return \"\" rather than throwing so the caller's validation reports an\n // invalid key instead of a TypeError. (Behavior preserved from qa-architect's\n // original normalizeLicenseKey when this was extracted into the package.)\n if (typeof key !== 'string') return '';\n return key.trim().toUpperCase();\n}\n","/**\n * Owner / developer mode — the kit CREATOR runs every pro feature without\n * paying themselves, while CUSTOMERS still hit the full signed-registry check.\n *\n * One implementation so every product (qa-architect, claude-kit-pro, ...) treats\n * the owner identically, parameterised per-product by env-var name and marker\n * path. Extracted from qa-architect's lib/licensing.js isDeveloperMode() so the\n * two products stop drifting (claude-kit-pro had no owner path at all).\n *\n * Security: NODE_ENV=production HARD-disables the bypass, so a customer cannot\n * set the env var or drop a marker file to cheat the gate.\n */\n\nimport { existsSync } from 'fs';\n\nexport interface DeveloperModeConfig {\n /** Env var that, when \"true\", enables owner mode. e.g. \"QAA_DEVELOPER\", \"CKIT_DEVELOPER\". */\n envVar: string;\n /** Absolute path to the marker file whose existence enables owner mode. */\n markerFile: string;\n}\n\n/**\n * Returns true when the current machine is the kit owner's dev environment.\n *\n * Never returns true under NODE_ENV=production. Otherwise true when either the\n * configured env var is \"true\" OR the marker file exists. An ELOOP (symlink\n * loop) on the marker path throws in production (tamper signal) and is treated\n * as \"no marker\" elsewhere.\n */\nexport function isDeveloperMode(config: DeveloperModeConfig): boolean {\n if (process.env.NODE_ENV === 'production') return false;\n if (process.env[config.envVar] === 'true') return true;\n try {\n if (existsSync(config.markerFile)) return true;\n } catch (err) {\n const code = (err as NodeJS.ErrnoException)?.code;\n if (code === 'ELOOP' && process.env.NODE_ENV === 'production') {\n throw new Error('Symlink loop detected in license marker path — possible tampering');\n }\n // Any other error (incl. ENOENT) means \"no usable marker\".\n }\n return false;\n}\n"],"mappings":"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","names":["cryptoSign","cryptoVerify","import_crypto"]}
package/dist/index.d.cts CHANGED
@@ -154,4 +154,32 @@ declare function licenseKeyPattern(prefix: string): RegExp;
154
154
  declare function isValidLicenseKey(key: string, prefix: string): boolean;
155
155
  declare function normalizeLicenseKey(key: string): string;
156
156
 
157
- export { type LicensePayload, type Registry, type RegistryEntry, type RegistryMetadata, type SignedRegistry, type Tier, type ValidatedEntry, type ValidationFailure, type ValidationResult, buildLicensePayload, buildSignedRegistry, computeHash, hashEmail, isValidLicenseKey, licenseKeyPattern, normalizeEmail, normalizeLicenseKey, signPayload, stableStringify, timingSafeStringEqual, validateRegistryEntry, verifyPayload, verifyRegistryMetadata };
157
+ /**
158
+ * Owner / developer mode — the kit CREATOR runs every pro feature without
159
+ * paying themselves, while CUSTOMERS still hit the full signed-registry check.
160
+ *
161
+ * One implementation so every product (qa-architect, claude-kit-pro, ...) treats
162
+ * the owner identically, parameterised per-product by env-var name and marker
163
+ * path. Extracted from qa-architect's lib/licensing.js isDeveloperMode() so the
164
+ * two products stop drifting (claude-kit-pro had no owner path at all).
165
+ *
166
+ * Security: NODE_ENV=production HARD-disables the bypass, so a customer cannot
167
+ * set the env var or drop a marker file to cheat the gate.
168
+ */
169
+ interface DeveloperModeConfig {
170
+ /** Env var that, when "true", enables owner mode. e.g. "QAA_DEVELOPER", "CKIT_DEVELOPER". */
171
+ envVar: string;
172
+ /** Absolute path to the marker file whose existence enables owner mode. */
173
+ markerFile: string;
174
+ }
175
+ /**
176
+ * Returns true when the current machine is the kit owner's dev environment.
177
+ *
178
+ * Never returns true under NODE_ENV=production. Otherwise true when either the
179
+ * configured env var is "true" OR the marker file exists. An ELOOP (symlink
180
+ * loop) on the marker path throws in production (tamper signal) and is treated
181
+ * as "no marker" elsewhere.
182
+ */
183
+ declare function isDeveloperMode(config: DeveloperModeConfig): boolean;
184
+
185
+ export { type DeveloperModeConfig, type LicensePayload, type Registry, type RegistryEntry, type RegistryMetadata, type SignedRegistry, type Tier, type ValidatedEntry, type ValidationFailure, type ValidationResult, buildLicensePayload, buildSignedRegistry, computeHash, hashEmail, isDeveloperMode, isValidLicenseKey, licenseKeyPattern, normalizeEmail, normalizeLicenseKey, signPayload, stableStringify, timingSafeStringEqual, validateRegistryEntry, verifyPayload, verifyRegistryMetadata };
package/dist/index.d.ts CHANGED
@@ -154,4 +154,32 @@ declare function licenseKeyPattern(prefix: string): RegExp;
154
154
  declare function isValidLicenseKey(key: string, prefix: string): boolean;
155
155
  declare function normalizeLicenseKey(key: string): string;
156
156
 
157
- export { type LicensePayload, type Registry, type RegistryEntry, type RegistryMetadata, type SignedRegistry, type Tier, type ValidatedEntry, type ValidationFailure, type ValidationResult, buildLicensePayload, buildSignedRegistry, computeHash, hashEmail, isValidLicenseKey, licenseKeyPattern, normalizeEmail, normalizeLicenseKey, signPayload, stableStringify, timingSafeStringEqual, validateRegistryEntry, verifyPayload, verifyRegistryMetadata };
157
+ /**
158
+ * Owner / developer mode — the kit CREATOR runs every pro feature without
159
+ * paying themselves, while CUSTOMERS still hit the full signed-registry check.
160
+ *
161
+ * One implementation so every product (qa-architect, claude-kit-pro, ...) treats
162
+ * the owner identically, parameterised per-product by env-var name and marker
163
+ * path. Extracted from qa-architect's lib/licensing.js isDeveloperMode() so the
164
+ * two products stop drifting (claude-kit-pro had no owner path at all).
165
+ *
166
+ * Security: NODE_ENV=production HARD-disables the bypass, so a customer cannot
167
+ * set the env var or drop a marker file to cheat the gate.
168
+ */
169
+ interface DeveloperModeConfig {
170
+ /** Env var that, when "true", enables owner mode. e.g. "QAA_DEVELOPER", "CKIT_DEVELOPER". */
171
+ envVar: string;
172
+ /** Absolute path to the marker file whose existence enables owner mode. */
173
+ markerFile: string;
174
+ }
175
+ /**
176
+ * Returns true when the current machine is the kit owner's dev environment.
177
+ *
178
+ * Never returns true under NODE_ENV=production. Otherwise true when either the
179
+ * configured env var is "true" OR the marker file exists. An ELOOP (symlink
180
+ * loop) on the marker path throws in production (tamper signal) and is treated
181
+ * as "no marker" elsewhere.
182
+ */
183
+ declare function isDeveloperMode(config: DeveloperModeConfig): boolean;
184
+
185
+ export { type DeveloperModeConfig, type LicensePayload, type Registry, type RegistryEntry, type RegistryMetadata, type SignedRegistry, type Tier, type ValidatedEntry, type ValidationFailure, type ValidationResult, buildLicensePayload, buildSignedRegistry, computeHash, hashEmail, isDeveloperMode, isValidLicenseKey, licenseKeyPattern, normalizeEmail, normalizeLicenseKey, signPayload, stableStringify, timingSafeStringEqual, validateRegistryEntry, verifyPayload, verifyRegistryMetadata };
package/dist/index.js CHANGED
@@ -147,16 +147,35 @@ function licenseKeyPattern(prefix) {
147
147
  return new RegExp(`^${prefix}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$`);
148
148
  }
149
149
  function isValidLicenseKey(key, prefix) {
150
+ if (typeof key !== "string") return false;
150
151
  return licenseKeyPattern(prefix).test(key.trim().toUpperCase());
151
152
  }
152
153
  function normalizeLicenseKey(key) {
154
+ if (typeof key !== "string") return "";
153
155
  return key.trim().toUpperCase();
154
156
  }
157
+
158
+ // src/developer-mode.ts
159
+ import { existsSync } from "fs";
160
+ function isDeveloperMode(config) {
161
+ if (process.env.NODE_ENV === "production") return false;
162
+ if (process.env[config.envVar] === "true") return true;
163
+ try {
164
+ if (existsSync(config.markerFile)) return true;
165
+ } catch (err) {
166
+ const code = err?.code;
167
+ if (code === "ELOOP" && process.env.NODE_ENV === "production") {
168
+ throw new Error("Symlink loop detected in license marker path \u2014 possible tampering");
169
+ }
170
+ }
171
+ return false;
172
+ }
155
173
  export {
156
174
  buildLicensePayload,
157
175
  buildSignedRegistry,
158
176
  computeHash,
159
177
  hashEmail,
178
+ isDeveloperMode,
160
179
  isValidLicenseKey,
161
180
  licenseKeyPattern,
162
181
  normalizeEmail,
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/signing.ts","../src/payload.ts","../src/registry.ts","../src/validator.ts","../src/key-format.ts"],"sourcesContent":["/**\n * Deterministic stringify + RSA-SHA256 sign/verify primitives.\n *\n * stableStringify must produce byte-identical output to QA Architect's\n * shipped lib/license-signing.js — the deployed CLI in customers' hands\n * uses that exact algorithm. Any divergence here breaks every QAA license\n * issued to date.\n */\n\nimport { sign as cryptoSign, verify as cryptoVerify, createHash } from 'crypto';\n\nexport function stableStringify(value: unknown, seen: WeakSet<object> = new WeakSet()): string {\n if (value === null || typeof value !== 'object') {\n return JSON.stringify(value);\n }\n if (seen.has(value as object)) {\n throw new Error('Circular reference detected in payload - cannot serialize');\n }\n seen.add(value as object);\n\n if (Array.isArray(value)) {\n return `[${value.map((item) => stableStringify(item, seen)).join(',')}]`;\n }\n const keys = Object.keys(value as Record<string, unknown>).sort();\n const entries = keys.map(\n (key) =>\n `${JSON.stringify(key)}:${stableStringify((value as Record<string, unknown>)[key], seen)}`,\n );\n return `{${entries.join(',')}}`;\n}\n\nexport function signPayload(payload: unknown, privateKeyPem: string): string {\n const data = Buffer.from(stableStringify(payload));\n return cryptoSign(null, data, privateKeyPem).toString('base64');\n}\n\nexport function verifyPayload(payload: unknown, signature: string, publicKeyPem: string): boolean {\n try {\n const data = Buffer.from(stableStringify(payload));\n return cryptoVerify(null, data, publicKeyPem, Buffer.from(signature, 'base64'));\n } catch {\n return false;\n }\n}\n\nexport function computeHash(data: string): string {\n return createHash('sha256').update(data).digest('hex');\n}\n\n/**\n * Constant-time string comparison. Same length precondition is checked\n * outside the comparison loop to avoid leaking length info.\n */\nexport function timingSafeStringEqual(a: string, b: string): boolean {\n if (a.length !== b.length) return false;\n let diff = 0;\n for (let i = 0; i < a.length; i++) {\n diff |= a.charCodeAt(i) ^ b.charCodeAt(i);\n }\n return diff === 0;\n}\n","/**\n * Email normalization, hashing, and license payload construction.\n *\n * buildLicensePayload is the contract the fulfillment service signs against\n * and that every client must rebuild bit-for-bit before verification. Adding\n * fields here = breaking change.\n */\n\nimport { createHash } from 'crypto';\nimport type { LicensePayload, Tier } from './types.js';\n\nexport function normalizeEmail(email: string): string | null {\n if (!email || typeof email !== 'string') return null;\n const normalized = email.trim().toLowerCase();\n if (!/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(normalized)) return null;\n return normalized.length > 0 ? normalized : null;\n}\n\nexport function hashEmail(email: string): string | null {\n const normalized = normalizeEmail(email);\n if (!normalized) return null;\n return createHash('sha256').update(normalized).digest('hex');\n}\n\nexport function buildLicensePayload(opts: {\n licenseKey: string;\n tier: Tier;\n isFounder: boolean;\n issued: string;\n emailHash?: string | null;\n}): LicensePayload {\n if (!opts.licenseKey || typeof opts.licenseKey !== 'string') {\n throw new Error('licenseKey is required and must be a string');\n }\n if (!opts.tier || typeof opts.tier !== 'string') {\n throw new Error('tier is required and must be a string');\n }\n if (!opts.issued || typeof opts.issued !== 'string') {\n throw new Error('issued is required and must be a string');\n }\n\n const payload: LicensePayload = {\n licenseKey: opts.licenseKey,\n tier: opts.tier,\n isFounder: Boolean(opts.isFounder),\n issued: opts.issued,\n };\n if (opts.emailHash) {\n payload.emailHash = opts.emailHash;\n }\n return payload;\n}\n","/**\n * Build a complete signed registry from a flat entries map.\n *\n * The registry signature covers ONLY the entries — _metadata is excluded.\n * QAA's deployed validator destructures `_metadata` out before verifying,\n * so any change to what's signed will break compatibility.\n */\n\nimport { computeHash, signPayload, stableStringify } from './signing.js';\nimport type { Registry, SignedRegistry } from './types.js';\n\nexport function buildSignedRegistry(\n entries: Registry,\n privateKeyPem: string,\n keyId = 'default',\n): SignedRegistry {\n const now = new Date().toISOString();\n const entriesStr = stableStringify(entries);\n const registrySignature = signPayload(entries, privateKeyPem);\n const hash = computeHash(entriesStr);\n\n return {\n _metadata: {\n version: '1.0',\n created: now,\n lastUpdate: now,\n description: 'License registry — populated by fulfillment webhook',\n algorithm: 'rsa-sha256',\n keyId,\n registrySignature,\n hash,\n totalLicenses: Object.keys(entries).length,\n },\n ...entries,\n };\n}\n","/**\n * Pure validation helpers — no I/O, no caching, no env.\n *\n * Both QA Architect's CLI and claude-kit-pro's MCP server use these\n * to verify a registry response. Anything that touches disk, network,\n * or process.env stays in the consuming product. This is the seam\n * that prevents the two validators from drifting apart.\n */\n\nimport { buildLicensePayload } from './payload.js';\nimport { computeHash, stableStringify, timingSafeStringEqual, verifyPayload } from './signing.js';\nimport type { RegistryEntry, SignedRegistry } from './types.js';\n\nexport interface ValidatedEntry {\n valid: true;\n tier: RegistryEntry['tier'];\n isFounder: boolean;\n customerId: string;\n keyId: string;\n}\n\nexport interface ValidationFailure {\n valid: false;\n error: string;\n}\n\nexport type ValidationResult = ValidatedEntry | ValidationFailure;\n\n/**\n * Verify a single registry entry against its embedded signature.\n * Optionally check the user's email hash against the entry's emailHash.\n *\n * Mirrors QAA's validateLicense() field-set exactly:\n * payload = { licenseKey, tier, isFounder, issued, emailHash? }\n */\nexport function validateRegistryEntry(opts: {\n licenseKey: string;\n entry: RegistryEntry;\n publicKeyPem: string;\n /** If supplied, must match entry.emailHash (timing-safe). */\n userEmailHash?: string;\n}): ValidationResult {\n const { licenseKey, entry, publicKeyPem, userEmailHash } = opts;\n\n if (entry.emailHash && userEmailHash && !timingSafeStringEqual(userEmailHash, entry.emailHash)) {\n return { valid: false, error: 'Email address does not match license registration' };\n }\n\n const payload = buildLicensePayload({\n licenseKey,\n tier: entry.tier,\n isFounder: entry.isFounder,\n issued: entry.issued,\n emailHash: entry.emailHash,\n });\n\n if (!verifyPayload(payload, entry.signature, publicKeyPem)) {\n return { valid: false, error: 'License entry signature verification failed' };\n }\n\n return {\n valid: true,\n tier: entry.tier,\n isFounder: entry.isFounder,\n customerId: entry.customerId,\n keyId: entry.keyId,\n };\n}\n\n/**\n * Verify a complete signed registry: registry-level signature + hash check.\n * Returns the entries map (with _metadata stripped) on success, throws on failure.\n *\n * Throws (rather than returning a result) because a registry signature failure\n * should halt validation entirely — clients should not fall back to entries\n * from an unverified registry.\n */\nexport function verifyRegistryMetadata(\n signedRegistry: SignedRegistry,\n publicKeyPem: string,\n): Record<string, RegistryEntry> {\n const { _metadata, ...entries } = signedRegistry;\n\n if (!_metadata?.registrySignature) {\n throw new Error('Registry missing _metadata.registrySignature');\n }\n\n if (!verifyPayload(entries, _metadata.registrySignature, publicKeyPem)) {\n throw new Error('Registry signature verification failed');\n }\n\n if (_metadata.hash) {\n const computed = computeHash(stableStringify(entries));\n if (!timingSafeStringEqual(computed, _metadata.hash)) {\n throw new Error('Registry hash mismatch');\n }\n }\n\n return entries as Record<string, RegistryEntry>;\n}\n","/**\n * Per-product license key format.\n *\n * QAA-XXXX-XXXX-XXXX-XXXX, CKIT-XXXX-XXXX-XXXX-XXXX, etc.\n * One factory so every product validates the same way.\n */\n\nexport function licenseKeyPattern(prefix: string): RegExp {\n if (!/^[A-Z0-9]+$/.test(prefix)) {\n throw new Error(`Prefix must be uppercase alphanumeric: ${prefix}`);\n }\n return new RegExp(`^${prefix}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$`);\n}\n\nexport function isValidLicenseKey(key: string, prefix: string): boolean {\n return licenseKeyPattern(prefix).test(key.trim().toUpperCase());\n}\n\nexport function normalizeLicenseKey(key: string): string {\n return key.trim().toUpperCase();\n}\n"],"mappings":"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","names":["createHash"]}
1
+ {"version":3,"sources":["../src/signing.ts","../src/payload.ts","../src/registry.ts","../src/validator.ts","../src/key-format.ts","../src/developer-mode.ts"],"sourcesContent":["/**\n * Deterministic stringify + RSA-SHA256 sign/verify primitives.\n *\n * stableStringify must produce byte-identical output to QA Architect's\n * shipped lib/license-signing.js — the deployed CLI in customers' hands\n * uses that exact algorithm. Any divergence here breaks every QAA license\n * issued to date.\n */\n\nimport { sign as cryptoSign, verify as cryptoVerify, createHash } from 'crypto';\n\nexport function stableStringify(value: unknown, seen: WeakSet<object> = new WeakSet()): string {\n if (value === null || typeof value !== 'object') {\n return JSON.stringify(value);\n }\n if (seen.has(value as object)) {\n throw new Error('Circular reference detected in payload - cannot serialize');\n }\n seen.add(value as object);\n\n if (Array.isArray(value)) {\n return `[${value.map((item) => stableStringify(item, seen)).join(',')}]`;\n }\n const keys = Object.keys(value as Record<string, unknown>).sort();\n const entries = keys.map(\n (key) =>\n `${JSON.stringify(key)}:${stableStringify((value as Record<string, unknown>)[key], seen)}`,\n );\n return `{${entries.join(',')}}`;\n}\n\nexport function signPayload(payload: unknown, privateKeyPem: string): string {\n const data = Buffer.from(stableStringify(payload));\n return cryptoSign(null, data, privateKeyPem).toString('base64');\n}\n\nexport function verifyPayload(payload: unknown, signature: string, publicKeyPem: string): boolean {\n try {\n const data = Buffer.from(stableStringify(payload));\n return cryptoVerify(null, data, publicKeyPem, Buffer.from(signature, 'base64'));\n } catch {\n return false;\n }\n}\n\nexport function computeHash(data: string): string {\n return createHash('sha256').update(data).digest('hex');\n}\n\n/**\n * Constant-time string comparison. Same length precondition is checked\n * outside the comparison loop to avoid leaking length info.\n */\nexport function timingSafeStringEqual(a: string, b: string): boolean {\n if (a.length !== b.length) return false;\n let diff = 0;\n for (let i = 0; i < a.length; i++) {\n diff |= a.charCodeAt(i) ^ b.charCodeAt(i);\n }\n return diff === 0;\n}\n","/**\n * Email normalization, hashing, and license payload construction.\n *\n * buildLicensePayload is the contract the fulfillment service signs against\n * and that every client must rebuild bit-for-bit before verification. Adding\n * fields here = breaking change.\n */\n\nimport { createHash } from 'crypto';\nimport type { LicensePayload, Tier } from './types.js';\n\nexport function normalizeEmail(email: string): string | null {\n if (!email || typeof email !== 'string') return null;\n const normalized = email.trim().toLowerCase();\n if (!/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(normalized)) return null;\n return normalized.length > 0 ? normalized : null;\n}\n\nexport function hashEmail(email: string): string | null {\n const normalized = normalizeEmail(email);\n if (!normalized) return null;\n return createHash('sha256').update(normalized).digest('hex');\n}\n\nexport function buildLicensePayload(opts: {\n licenseKey: string;\n tier: Tier;\n isFounder: boolean;\n issued: string;\n emailHash?: string | null;\n}): LicensePayload {\n if (!opts.licenseKey || typeof opts.licenseKey !== 'string') {\n throw new Error('licenseKey is required and must be a string');\n }\n if (!opts.tier || typeof opts.tier !== 'string') {\n throw new Error('tier is required and must be a string');\n }\n if (!opts.issued || typeof opts.issued !== 'string') {\n throw new Error('issued is required and must be a string');\n }\n\n const payload: LicensePayload = {\n licenseKey: opts.licenseKey,\n tier: opts.tier,\n isFounder: Boolean(opts.isFounder),\n issued: opts.issued,\n };\n if (opts.emailHash) {\n payload.emailHash = opts.emailHash;\n }\n return payload;\n}\n","/**\n * Build a complete signed registry from a flat entries map.\n *\n * The registry signature covers ONLY the entries — _metadata is excluded.\n * QAA's deployed validator destructures `_metadata` out before verifying,\n * so any change to what's signed will break compatibility.\n */\n\nimport { computeHash, signPayload, stableStringify } from './signing.js';\nimport type { Registry, SignedRegistry } from './types.js';\n\nexport function buildSignedRegistry(\n entries: Registry,\n privateKeyPem: string,\n keyId = 'default',\n): SignedRegistry {\n const now = new Date().toISOString();\n const entriesStr = stableStringify(entries);\n const registrySignature = signPayload(entries, privateKeyPem);\n const hash = computeHash(entriesStr);\n\n return {\n _metadata: {\n version: '1.0',\n created: now,\n lastUpdate: now,\n description: 'License registry — populated by fulfillment webhook',\n algorithm: 'rsa-sha256',\n keyId,\n registrySignature,\n hash,\n totalLicenses: Object.keys(entries).length,\n },\n ...entries,\n };\n}\n","/**\n * Pure validation helpers — no I/O, no caching, no env.\n *\n * Both QA Architect's CLI and claude-kit-pro's MCP server use these\n * to verify a registry response. Anything that touches disk, network,\n * or process.env stays in the consuming product. This is the seam\n * that prevents the two validators from drifting apart.\n */\n\nimport { buildLicensePayload } from './payload.js';\nimport { computeHash, stableStringify, timingSafeStringEqual, verifyPayload } from './signing.js';\nimport type { RegistryEntry, SignedRegistry } from './types.js';\n\nexport interface ValidatedEntry {\n valid: true;\n tier: RegistryEntry['tier'];\n isFounder: boolean;\n customerId: string;\n keyId: string;\n}\n\nexport interface ValidationFailure {\n valid: false;\n error: string;\n}\n\nexport type ValidationResult = ValidatedEntry | ValidationFailure;\n\n/**\n * Verify a single registry entry against its embedded signature.\n * Optionally check the user's email hash against the entry's emailHash.\n *\n * Mirrors QAA's validateLicense() field-set exactly:\n * payload = { licenseKey, tier, isFounder, issued, emailHash? }\n */\nexport function validateRegistryEntry(opts: {\n licenseKey: string;\n entry: RegistryEntry;\n publicKeyPem: string;\n /** If supplied, must match entry.emailHash (timing-safe). */\n userEmailHash?: string;\n}): ValidationResult {\n const { licenseKey, entry, publicKeyPem, userEmailHash } = opts;\n\n if (entry.emailHash && userEmailHash && !timingSafeStringEqual(userEmailHash, entry.emailHash)) {\n return { valid: false, error: 'Email address does not match license registration' };\n }\n\n const payload = buildLicensePayload({\n licenseKey,\n tier: entry.tier,\n isFounder: entry.isFounder,\n issued: entry.issued,\n emailHash: entry.emailHash,\n });\n\n if (!verifyPayload(payload, entry.signature, publicKeyPem)) {\n return { valid: false, error: 'License entry signature verification failed' };\n }\n\n return {\n valid: true,\n tier: entry.tier,\n isFounder: entry.isFounder,\n customerId: entry.customerId,\n keyId: entry.keyId,\n };\n}\n\n/**\n * Verify a complete signed registry: registry-level signature + hash check.\n * Returns the entries map (with _metadata stripped) on success, throws on failure.\n *\n * Throws (rather than returning a result) because a registry signature failure\n * should halt validation entirely — clients should not fall back to entries\n * from an unverified registry.\n */\nexport function verifyRegistryMetadata(\n signedRegistry: SignedRegistry,\n publicKeyPem: string,\n): Record<string, RegistryEntry> {\n const { _metadata, ...entries } = signedRegistry;\n\n if (!_metadata?.registrySignature) {\n throw new Error('Registry missing _metadata.registrySignature');\n }\n\n if (!verifyPayload(entries, _metadata.registrySignature, publicKeyPem)) {\n throw new Error('Registry signature verification failed');\n }\n\n if (_metadata.hash) {\n const computed = computeHash(stableStringify(entries));\n if (!timingSafeStringEqual(computed, _metadata.hash)) {\n throw new Error('Registry hash mismatch');\n }\n }\n\n return entries as Record<string, RegistryEntry>;\n}\n","/**\n * Per-product license key format.\n *\n * QAA-XXXX-XXXX-XXXX-XXXX, CKIT-XXXX-XXXX-XXXX-XXXX, etc.\n * One factory so every product validates the same way.\n */\n\nexport function licenseKeyPattern(prefix: string): RegExp {\n if (!/^[A-Z0-9]+$/.test(prefix)) {\n throw new Error(`Prefix must be uppercase alphanumeric: ${prefix}`);\n }\n return new RegExp(`^${prefix}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$`);\n}\n\nexport function isValidLicenseKey(key: string, prefix: string): boolean {\n if (typeof key !== 'string') return false;\n return licenseKeyPattern(prefix).test(key.trim().toUpperCase());\n}\n\nexport function normalizeLicenseKey(key: string): string {\n // Defensive: callers pass keys straight from license files / env / CLI args,\n // any of which can be undefined or non-string for incomplete/corrupt input.\n // Return \"\" rather than throwing so the caller's validation reports an\n // invalid key instead of a TypeError. (Behavior preserved from qa-architect's\n // original normalizeLicenseKey when this was extracted into the package.)\n if (typeof key !== 'string') return '';\n return key.trim().toUpperCase();\n}\n","/**\n * Owner / developer mode — the kit CREATOR runs every pro feature without\n * paying themselves, while CUSTOMERS still hit the full signed-registry check.\n *\n * One implementation so every product (qa-architect, claude-kit-pro, ...) treats\n * the owner identically, parameterised per-product by env-var name and marker\n * path. Extracted from qa-architect's lib/licensing.js isDeveloperMode() so the\n * two products stop drifting (claude-kit-pro had no owner path at all).\n *\n * Security: NODE_ENV=production HARD-disables the bypass, so a customer cannot\n * set the env var or drop a marker file to cheat the gate.\n */\n\nimport { existsSync } from 'fs';\n\nexport interface DeveloperModeConfig {\n /** Env var that, when \"true\", enables owner mode. e.g. \"QAA_DEVELOPER\", \"CKIT_DEVELOPER\". */\n envVar: string;\n /** Absolute path to the marker file whose existence enables owner mode. */\n markerFile: string;\n}\n\n/**\n * Returns true when the current machine is the kit owner's dev environment.\n *\n * Never returns true under NODE_ENV=production. Otherwise true when either the\n * configured env var is \"true\" OR the marker file exists. An ELOOP (symlink\n * loop) on the marker path throws in production (tamper signal) and is treated\n * as \"no marker\" elsewhere.\n */\nexport function isDeveloperMode(config: DeveloperModeConfig): boolean {\n if (process.env.NODE_ENV === 'production') return false;\n if (process.env[config.envVar] === 'true') return true;\n try {\n if (existsSync(config.markerFile)) return true;\n } catch (err) {\n const code = (err as NodeJS.ErrnoException)?.code;\n if (code === 'ELOOP' && process.env.NODE_ENV === 'production') {\n throw new Error('Symlink loop detected in license marker path — possible tampering');\n }\n // Any other error (incl. ENOENT) means \"no usable marker\".\n }\n return false;\n}\n"],"mappings":"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","names":["createHash"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildproven/license-core",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Shared license signing & verification primitives for BuildProven products (RSA-SHA256, signed registry).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -29,6 +29,7 @@
29
29
  },
30
30
  "devDependencies": {
31
31
  "@types/node": "^22.0.0",
32
+ "fast-check": "^4.7.0",
32
33
  "prettier": "^3.0.0",
33
34
  "tsup": "^8.0.0",
34
35
  "typescript": "^5.4.0",