Initial Commit
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# 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")`)
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
Reference in New Issue
Block a user