12 KiB
Capability Spec Delta: license (enterprise-license)
Package: @suaveplan/license
Status: PROPOSED
ADDED Requirements
Requirement: Crockford Base32 codec
The package SHALL provide base32Encode(bytes): string and base32Decode(str): Uint8Array using BASE32_ALPHABET (omits I/L/O/U), encoder inserts - every 5 chars, decoder is dash-tolerant and case-insensitive. Non-alphabet characters SHALL raise LicenseError(LICENSE_INVALID_ENCODING).
Scenario: round-trip
- WHEN
base32Decode(base32Encode(b))is evaluated - THEN the result equals
b
Verified by: src/codec/base32/base32.test.ts (describe("base32Encode / base32Decode"))
Requirement: Bit-precise pack/unpack primitives
The package SHALL provide SimpleBitPacker.packUint(value, bits) / getBytes() / getBitCount() and SimpleBitUnpacker.unpackUint(bits) / getBitPosition(). Out-of-range values SHALL throw; reads past end SHALL throw a buffer-underflow error.
Scenario: round-trip
- WHEN values are packed in order and then unpacked with the same widths
- THEN the unpacked values equal the originals
Verified by: src/codec/bitpack/bitpack.test.ts (describe("SimpleBitPacker / SimpleBitUnpacker"))
Requirement: 72-bit payload pack and unpack
packPayload SHALL return exactly 9 bytes; unpackPayload SHALL require 9 bytes. Field widths SHALL match LICENSE_SCHEMA. Out-of-range fields SHALL raise LicenseError(LICENSE_PAYLOAD_OUT_OF_RANGE); wrong-length input SHALL raise LicenseError(LICENSE_MALFORMED).
Scenario: every field round-trips
- WHEN
unpackPayload(packPayload(p))is evaluated - THEN every field equals the original
Verified by: src/codec/payload/payload.test.ts (describe("packPayload / unpackPayload")) AND src/license.spec-conformance.test.ts::"LICENSE.PAYLOAD_OUT_OF_RANGE is thrown from packPayload"
Requirement: Signature-derived obfuscation pass
obfuscateLicenseKey / deobfuscateLicenseKey SHALL rotate the first 15 non-dash chars within BASE32_ALPHABET by per-char offsets from the supplied seed. extractSignatureFromKey SHALL derive the seed from alphabet indices of unobfuscated chars 16-30 so the seed is recoverable from the obfuscated key. Keys with fewer than 15 non-dash chars SHALL pass through unchanged.
Scenario: round-trip
- WHEN
deobfuscateLicenseKey(obfuscateLicenseKey(k, s), s)is evaluated - THEN the result equals
k
Verified by: src/codec/obfuscation/obfuscation.test.ts (describe("obfuscation"))
Requirement: Four time-encoding strategies with injectable Clock
checkExpiration(type, value, refDate?, creationDate?) SHALL implement TimeEncodingType.{NONE, ABSOLUTE_DAYS, RELATIVE_DAYS, ABSOLUTE_TIMESTAMP}. getDayNumber(date?, clock?) and other time helpers SHALL accept an optional Clock from @suaveplan/types, defaulting to wall time.
Scenario: each strategy's boundary
- WHEN
checkExpirationis invoked for each strategy with reference dates straddling the expiration boundary - THEN
isExpiredflips at the boundary;NONEis always false;RELATIVE_DAYSusescreationDateas the baseline
Verified by: src/time/time.test.ts (describe("time helpers"))
Requirement: Flags and feature-mask helpers
hasFlag, combineFlags, hasFeature, combineFeatures, getEnabledFeatures SHALL operate on the 5-bit flag and 19-bit feature mask. hasFeature / combineFeatures SHALL raise LicenseError(LICENSE_FEATURE_INDEX_OUT_OF_RANGE) for indices outside [0, 18].
Scenario: out-of-range index throws
- WHEN
hasFeature(0, 19)orcombineFeatures(0, 19)is called - THEN
LicenseError(LICENSE_FEATURE_INDEX_OUT_OF_RANGE)is thrown
Verified by: src/flags/flags.test.ts (describe("flag and feature helpers")) AND src/license.spec-conformance.test.ts::"LICENSE.FEATURE_INDEX_OUT_OF_RANGE is thrown from hasFeature/combineFeatures"
Requirement: License generation with full and truncated signatures
generateLicense(payload, options) SHALL pack payload, sign with secp256k1 over SHA-256 digest via @suaveplan/crypto-curve + @suaveplan/crypto-hash, optionally embed a truncated (default-20-byte) hash-prefix verifier, Base32-encode, obfuscate. createPayload(options?, injection?) SHALL fill LicensePayload defaults from QuickLicenseOptions with an injectable Rng for nonce and Clock for absolute-day expirations. parseLicense(key) SHALL decode without verification.
Scenario: generated key parses back to its payload
- WHEN
parseLicense(generateLicense(p, { signingKey }).key).payloadis evaluated - THEN the result equals
p(aftersigningKeyIdOverrideapplied)
Verified by: src/generate/generate.test.ts (describe("generateLicense"), describe("createPayload"), describe("parseLicense"))
Requirement: License validation discriminated-union result
validateLicense(key, options) SHALL return { success: true, details } or { success: false, error, message, partialDetails? } covering seven failure modes: MALFORMED, INVALID_ENCODING, INVALID_SIGNATURE, UNKNOWN_SIGNING_KEY, EXPIRED, UNSUPPORTED_VERSION, REVOKED. Full 64-byte sigs SHALL verify via standard secp256k1; truncated sigs SHALL verify via the hash-prefix scheme (SHA-256 over payloadBytes || publicKey.p, compare first N bytes). Accepts publicKeys as Map<number, PublicKey> or Record<number, PublicKey>. isValidLicense SHALL be a boolean convenience wrapper.
Scenario: tampered payload yields INVALID_SIGNATURE
- WHEN any byte of a valid key's payload portion is flipped and the key is validated
- THEN the result has
error === LicenseValidationError.INVALID_SIGNATURE
Verified by: src/validate/validate.test.ts (describe("validateLicense"), describe("isValidLicense")) AND src/license.spec-conformance.test.ts (describe("spec-conformance (f) — LicenseValidationError literal-string enum"))
Requirement: LicenseManager class
LicenseManager SHALL accept LicenseManagerConfig (publicKeys / signingKey / defaultSigningKeyId / defaultProductId / clock / rng / revocationStore) and expose addPublicKey, setSigningKey, setRevocationStore, generate, generateTrial, validate, isValid, parse, revoke, isRevoked. Calling generate without a configured signing key SHALL raise LicenseError(LICENSE_NO_SIGNING_KEY).
Scenario: roundtrip via manager
- WHEN a manager configured with
signingKey+ matchingpublicKeygenerates a key and validates it - THEN
validatereturns{ success: true }
Verified by: src/manager/manager.test.ts (describe("LicenseManager"))
Requirement: Pluggable RevocationStore with in-memory implementation
The package SHALL ship RevocationStore (interface: add / has / list, each void|Promise<void> / boolean|Promise<boolean> / readonly string[]|Promise<readonly string[]>), createMemoryRevocationStore({ maxEntries? }) with LRU eviction, and fingerprintLicense(key) returning SHA-256 hex. A manager configured with a revocation store SHALL return LicenseValidationFailure{error: LicenseValidationError.REVOKED} for any key whose fingerprint is in the store. Async stores SHALL be awaited.
Scenario: revoked key validates as REVOKED
- WHEN
manager.revoke(fingerprintLicense(key))is called and thenmanager.validate(key)is awaited - THEN the result has
error === LicenseValidationError.REVOKED
Verified by: src/revocation/revocation-store/revocation-store.test.ts (describe("createMemoryRevocationStore"), describe("fingerprintLicense")) AND src/license.spec-conformance.test.ts::"LICENSE.REVOKED surfaced on manager.validate when fingerprint is in store"
Requirement: Zod schemas guard every public input
LicensePayloadSchema, LicenseGenerationOptionsSchema, LicenseValidationOptionsSchema, QuickLicenseOptionsSchema SHALL validate at boundary. Invalid input SHALL raise LicenseError(LICENSE_INVALID_INPUT) with the ZodError as cause.
Scenario: out-of-range field rejected
- WHEN
generateLicense({ ...valid, productId: 64 } as never, { signingKey })is awaited - THEN the promise rejects with
LicenseError(LICENSE_INVALID_INPUT)whosecauseis aZodError
Verified by: src/schemas/schemas.test.ts (describe("zod schemas")) AND src/license.spec-conformance.test.ts (describe("spec-conformance (b) — every Zod boundary is wired"))
Requirement: OpenTelemetry counters, histogram, and attributes
The package SHALL register license.generate_total (counter), license.validate_total (counter, attribute license.valid: boolean), and license.validate_duration_ms (histogram, unit ms) via @opentelemetry/api. Validation paths SHALL emit attributes regardless of success/failure.
Scenario: instruments fire from production paths
- WHEN generation and validation paths run against a real in-memory
MeterProvider - THEN both counters increment and the histogram has observations
Verified by: src/otel/instruments.test.ts (describe("OTel instruments")) AND src/license.spec-conformance.test.ts (describe("spec-conformance (c) — OTel instruments fire from production paths"))
Requirement: AbortSignal propagates to async operations
generateLicense, validateLicense, LicenseManager.generate, LicenseManager.validate SHALL accept signal?: AbortSignal. Pre-aborted or in-flight aborts SHALL reject with LicenseError(LICENSE_ABORTED).
Scenario: aborted signal rejects
- WHEN
generateLicense(payload, { signingKey, signal: AbortSignal.abort() })is awaited - THEN the promise rejects with
LicenseError(LICENSE_ABORTED)
Verified by: src/license.spec-conformance.test.ts (describe("spec-conformance (d) — AbortSignal propagates")) AND src/license.spec-conformance.test.ts::"LICENSE.ABORTED is thrown from generateLicense / validateLicense"
Requirement: Every LICENSE_* code is thrown from a production path
LICENSE_CODES SHALL define exactly 12 codes (INVALID_INPUT, INVALID_ENCODING, MALFORMED, PAYLOAD_OUT_OF_RANGE, UNSUPPORTED_VERSION, UNKNOWN_SIGNING_KEY, INVALID_SIGNATURE, EXPIRED, REVOKED, NO_SIGNING_KEY, FEATURE_INDEX_OUT_OF_RANGE, ABORTED). Each SHALL be raised by at least one production call site or surfaced via a LicenseValidationFailure.error enum mapping.
Scenario: every code is exercised
- WHEN the spec-conformance test exercises each code's production path
- THEN every
LICENSE_*code appears in the test assertions
Verified by: src/license.spec-conformance.test.ts (describe("spec-conformance (a) — every LICENSE_* code is thrown"))
Requirement: Universal-tier purity, subpath exports, sibling docs
The package SHALL run unchanged in Bun, Node, and the browser. Its package.json SHALL declare a subpath export per feature module plus the . barrel. Every implementation .ts (excluding tests, types, barrels, benchmarks) SHALL have a sibling .md of ≥200 words with Purpose / Features / Usage: Basic / Usage: Advanced / API Reference / Implementation Notes. No export * in any feature barrel. All test files SHALL import runner primitives from @suaveplan/testing/runner.
Scenario: package shape is clean
- WHEN dep-graph and conformance tests inspect file tree, package.json, and barrels
- THEN every assertion holds
Verified by: tests/dep-graph.test.ts (describe("dep-graph and subpath exports")) AND src/license.spec-conformance.test.ts (describe("spec-conformance — sibling .md presence (sentinel)"))
Requirement: 100% coverage + property-based round-trip and tamper-detection
The package SHALL enforce 100/100/100/100 statements/branches/functions/lines via Istanbul. tests/property.test.ts SHALL use fast-check to assert unpackPayload(packPayload(p)) equals p for arbitrary valid payloads AND any 1-byte mutation surfaces as INVALID_SIGNATURE or MALFORMED.
Scenario: coverage gate
- WHEN
bunx turbo test:coverage --filter=@suaveplan/licenseruns - THEN all four thresholds are 100
Verified by: tests/property.test.ts (describe("property tests"))