Files
2026-05-27 23:29:53 +01:00

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 checkExpiration is invoked for each strategy with reference dates straddling the expiration boundary
  • THEN isExpired flips at the boundary; NONE is always false; RELATIVE_DAYS uses creationDate as 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) or combineFeatures(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).payload is evaluated
  • THEN the result equals p (after signingKeyIdOverride applied)

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 + matching publicKey generates a key and validates it
  • THEN validate returns { 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 then manager.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) whose cause is a ZodError

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/license runs
  • THEN all four thresholds are 100

Verified by: tests/property.test.ts (describe("property tests"))