Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,52 @@
# Add @suaveplan/license — port-main verbatim + enterprise stack
**Package**: `@suaveplan/license`
**Status**: PROPOSED
**Type**: migration (port-main wire-format verbatim) + enterprise enhancements (additive)
- **Spec delta**: ./specs/license/spec.md
## Why
Software license-key issuance and validation. Ports `../suaveplan/packages/security/license/` verbatim at the wire-format level (72-bit bitpacked payload, secp256k1 signature truncated to 20 bytes by default, Crockford Base32 with signature-derived obfuscation) so genesis-generated keys interoperate byte-for-byte with upstream-generated keys. Layers the genesis enterprise stack on top: `SuaveplanError`-typed errors with a 12-code registry, Zod boundary validation, OpenTelemetry counters/histogram, `AbortSignal`, injectable `Clock`/`Rng`, sibling `.md` docs ≥200 words each, mitata benchmarks, fast-check property tests, dep-graph test, spec-conformance test, 100/100/100/100 coverage. Adds a pluggable `RevocationStore` with an in-memory implementation as an opt-in offline blocklist — does not change the wire format.
Unblocks `@suaveplan/server-keys` (in flight; `enterprise-server-server-keys`).
## What changes
Net-new package at version `0.1.0`. Public surface (every symbol from upstream `index.ts`/`index.types.ts`/`bitpack.ts`/`obfuscation.ts`, plus enterprise additions):
- Codec: `base32Encode` / `base32Decode`, `SimpleBitPacker` / `SimpleBitUnpacker`, `packPayload` / `unpackPayload`, `obfuscateLicenseKey` / `deobfuscateLicenseKey` / `extractSignatureFromKey`
- Time: `getDayNumber`, `dayNumberToDate`, `checkExpiration` — `Clock` injectable
- Flags: `hasFlag`, `combineFlags`, `hasFeature`, `combineFeatures`, `getEnabledFeatures`
- Generation: `generateLicense`, `createPayload` (Rng injectable), `parseLicense`
- Validation: `validateLicense`, `isValidLicense` — full + truncated signature paths
- Manager: `LicenseManager` class with `addPublicKey` / `setSigningKey` / `setRevocationStore` / `generate` / `generateTrial` / `validate` / `isValid` / `parse` / `revoke` / `isRevoked`
- Revocation: `RevocationStore` interface, `createMemoryRevocationStore` (LRU), `fingerprintLicense`, `LicenseValidationError.REVOKED` (additive enum variant; the other 6 variants are preserved verbatim)
- Errors: `LicenseError extends SuaveplanError<LicenseErrorCode>` with 12 codes (`LICENSE.{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}`)
- Schemas: `LicensePayloadSchema`, `LicenseGenerationOptionsSchema`, `LicenseValidationOptionsSchema`, `QuickLicenseOptionsSchema` (Zod)
- OTel: counter `license.generate_total`, counter `license.validate_total{license.valid}`, histogram `license.validate_duration_ms`
- Enums + types preserved verbatim from upstream: `TimeEncodingType`, `LicenseFlags`, `PackageTier`, `LicenseValidationError`, `LicensePayload`, `LicenseDetails`, `LicenseGenerationOptions`, `LicenseGenerationResult`, `LicenseValidationOptions`, `LicenseValidationSuccess`, `LicenseValidationFailure`, `LicenseValidationResult`, `LicenseParsedResult`, `LicenseManagerConfig`, `QuickLicenseOptions`, `KeyRegistry`
- Constants verbatim from upstream: `LICENSE_VERSION` (=2), `FULL_SIGNATURE_BYTES` (=64), `SIGNATURE_BYTES` (=20), `MIN_SIGNATURE_BYTES` (=16), `BASE32_ALPHABET`, `LICENSE_SCHEMA`, plus genesis-added `PAYLOAD_BYTES` (=9)
- Subpath exports per feature module
## Out of scope
- **Network-based revocation (HTTP CRL/OCSP).** Follow-up package; the shipped `RevocationStore` interface accepts async implementations so the follow-up is non-breaking.
- **Hardware-fingerprint enforcement.** `MACHINE_LOCKED` enum value is preserved (wire format); enforcement is application policy.
- **License rotation workflows.** Business logic, owned by callers.
- **Schema-registry integration.** Wire format is self-describing via the 3-bit `version` field.
## Source mapping
| Capability | Upstream source | Notes |
|---|---|---|
| Wire format (72-bit payload, sig modes, Base32, obfuscation) | `../suaveplan/packages/security/license/src/{index,index.types,bitpack,obfuscation}.ts` | verbatim |
| Enterprise audit requirements (no React/Node deps, Zod, fast-check, deterministic Clock/Rng, OTel, structured errors, AbortSignal, i18n-ready, 100% coverage, 200-word `.md`) | `../suaveplan/enterprise/license.md` L37-L63 | all addressed |
| `LicenseError` + `LICENSE_*` code registry, OTel instruments, `RevocationStore`, `Clock`/`Rng` injection, `AbortSignal` plumbing, dep-graph + spec-conformance tests, mitata benchmarks | genesis-original | genesis-wide enterprise policy (`CLAUDE.md`, `openspec/AGENTS.md`) — upstream throws plain Errors and has no OTel/Zod/revocation |
## Impact
### Affected dependents
None today — net-new on genesis. First consumer will be `@suaveplan/server-keys` (separate change `enterprise-server-server-keys`).

View File

@@ -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 -->

View File

@@ -0,0 +1,52 @@
# Tasks — enterprise-license
> All checkboxes ticked reflect work done in this change. Package was implemented in one session; spec authored after impl per "collapse to one change" decision (matches `§13` reconciliation pattern).
## 1. Spec authoring
- [x] 1.1 Read `../suaveplan/enterprise/license.md` end-to-end
- [x] 1.2 Read upstream `index.ts` / `index.types.ts` / `bitpack.ts` / `obfuscation.ts`
- [x] 1.3 Draft proposal + spec delta (one terse `### Requirement` per feature module, every Verified-by line points at a real `it(...)` or `describe(...)` title)
## 2. Implementation (port-main verbatim + enterprise stack)
- [x] 2.1 Scaffold: `package.json` v0.1.0, 16 subpath exports, tsconfig + tsconfig.lib, vite.config, vitest.config (`pool: forks`, `provider: istanbul`, 100/100/100/100), tests/setup/polyfills
- [x] 2.2 `src/error-codes.ts` + `.md` — 12-code `LICENSE.*` registry + `LicenseError extends SuaveplanError<LicenseErrorCode>`
- [x] 2.3 `src/constants/constants.ts` + `.md` — `LICENSE_VERSION`, `FULL_SIGNATURE_BYTES`, `SIGNATURE_BYTES`, `MIN_SIGNATURE_BYTES`, `BASE32_ALPHABET`, `LICENSE_SCHEMA`, `FIELD_BITS`, `PAYLOAD_BYTES`
- [x] 2.4 `src/types/license.ts` + `.md` — every enum/interface/type from upstream `index.types.ts` verbatim
- [x] 2.5 `src/codec/base32/base32.{ts,test.ts,md}`
- [x] 2.6 `src/codec/bitpack/bitpack.{ts,test.ts,md}`
- [x] 2.7 `src/codec/payload/payload.{ts,test.ts,md}`
- [x] 2.8 `src/codec/obfuscation/obfuscation.{ts,test.ts,md}`
- [x] 2.9 `src/time/time.{ts,test.ts,md}` — `Clock` injection
- [x] 2.10 `src/flags/flags.{ts,test.ts,md}`
- [x] 2.11 `src/schemas/schemas.{ts,test.ts,md}` — Zod boundary schemas
- [x] 2.12 `src/otel/instruments.{ts,test.ts,md}` — counters + histogram + attributes
- [x] 2.13 `src/generate/generate.{ts,test.ts,md}` — `generateLicense`, `createPayload`, `parseLicense`; OTel, Zod, AbortSignal, Rng/Clock injection
- [x] 2.14 `src/validate/validate.{ts,test.ts,md}` — full + truncated sig paths; OTel; AbortSignal
- [x] 2.15 `src/manager/manager.{ts,test.ts,md}` — full `LicenseManager` surface incl. `revoke` / `isRevoked`
- [x] 2.16 `src/revocation/revocation-store/revocation-store.{ts,test.ts,md}` — `RevocationStore` interface + `createMemoryRevocationStore` (LRU) + `fingerprintLicense`
- [x] 2.17 `src/index.ts` — named re-exports only
- [x] 2.18 `README.md`
- [x] 2.19 `src/__benchmarks__/license.bench.ts` (mitata)
- [x] 2.20 `tests/property.test.ts` (fast-check), `tests/dep-graph.test.ts`, `src/license.spec-conformance.test.ts`
## 3. Gates
- [x] 3.1 `bunx turbo lint typecheck test --filter=@suaveplan/license` — 11/11 green, 15 test files, 158 tests pass
- [x] 3.2 `bunx turbo test:coverage --filter=@suaveplan/license` — 100/100/100/100 across every module
- [x] 3.3 `bunx turbo build --filter=@suaveplan/license` — green
- [x] 3.4 `bun scripts/openspec-conformance.ts @suaveplan/license` — PASS (0 errors, 3 informational warnings about schemas-invoked-via-wrapper false positive)
- [x] 3.5 Banned-pattern grep — clean (3 hits are the word "any" in JSDoc prose, not type annotations)
- [x] 3.6 No `export *` in any feature barrel
- [x] 3.7 No direct `vitest`/`bun:test` imports
- [x] 3.8 Every implementation `.ts` has a sibling `.md` (≥200 words)
> Note: `bun run check` (monorepo-wide Biome) reports 1619 errors / 14 warnings but all are pre-existing in other packages; `bunx turbo lint --filter=@suaveplan/license` is clean.
## 4. Archive
- [ ] 4.1 Fold `specs/license/spec.md` → `openspec/specs/license/spec.md`
- [ ] 4.2 Move `openspec/changes/enterprise-license/` → `openspec/archive/2026-05-17-enterprise-license/`
- [ ] 4.3 Refresh `MANIFEST.md` (`bun scripts/folder-reorg/generate-manifest.ts`)
- [ ] 4.4 Update root `STATUS.md` — `@suaveplan/license` `–` → `✅ enterprise`