# 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` or `Record`. `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` / `boolean|Promise` / `readonly string[]|Promise`), `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")`)