first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,178 @@
# @suaveplan/license - Compact, offline-verifiable, secp256k1-signed software-license-key format: 72-bit bitpacked payload (version + signing-key id + product id + tier + flags + time encoding + time value + feature mask + nonce) plus full or truncated (20-byte) signature, Crockford-Base32 encoded with signature-derived obfuscation. suaveplan-enterprise additions: SuaveplanError-typed errors, OTel counters/histograms/spans, Zod boundary validation, injectable Clock/Rng, AbortSignal propagation, pluggable RevocationStore. Universal.
Compact, offline-verifiable, secp256k1-signed software-license-key
format. Bit-for-bit wire-format compatible with upstream
`@suaveplan/license`. runs unchanged in Bun, Node, and the browser.
<!-- begin:auto-docs -->
## Documentation tree
- [base32](./src/codec/base32/base32.md) — Crockford-style Base32 codec used to serialise the packed payload and signature into a printable license-key string. Th…
- [base32](./src/codec/base32/base32.md) — Crockford-style Base32 codec used to serialise the packed payload and signature into a printable license-key string. Th…
- [bitpack](./src/codec/bitpack/bitpack.md) — Bit-precise pack/unpack primitives used to serialise the 72-bit license payload into 9 bytes. Plain `DataView` / typed-…
- [bitpack](./src/codec/bitpack/bitpack.md) — Bit-precise pack/unpack primitives used to serialise the 72-bit license payload into 9 bytes. Plain `DataView` / typed-…
- [obfuscation](./src/codec/obfuscation/obfuscation.md) — Per-key character obfuscation pass run after Base32 encoding. The first 15 non-dash characters of the encoded key (whic…
- [obfuscation](./src/codec/obfuscation/obfuscation.md) — Per-key character obfuscation pass run after Base32 encoding. The first 15 non-dash characters of the encoded key (whic…
- [payload](./src/codec/payload/payload.md) — `packPayload` and `unpackPayload` are the 72-bit payload codec on top of the bitpack primitives. They emit exactly 9 by…
- [payload](./src/codec/payload/payload.md) — `packPayload` and `unpackPayload` are the 72-bit payload codec on top of the bitpack primitives. They emit exactly 9 by…
- [constants](./src/constants/constants.md) — Wire-format constants for the license package. Every value here is part of the binary contract: the per-field b…
- [constants](./src/constants/constants.md) — Wire-format constants for the license package. Every value here is part of the binary contract: the per-field b…
- [flags](./src/flags/flags.md) — Bitmask helpers for the 5-bit `flags` field (`LicenseFlags`) and the 19-bit `featureMask` field. The flags field stores…
- [flags](./src/flags/flags.md) — Bitmask helpers for the 5-bit `flags` field (`LicenseFlags`) and the 19-bit `featureMask` field. The flags field stores…
- [generate](./src/generate/generate.md) — Top-level license-key generation, payload construction, and parse-only inspection. This is the place that brings togeth…
- [generate](./src/generate/generate.md) — Top-level license-key generation, payload construction, and parse-only inspection. This is the place that brings togeth…
- [manager](./src/manager/manager.md) — High-level `LicenseManager` class that bundles a public-key registry, an optional signing key, default `signingKeyId` /…
- [manager](./src/manager/manager.md) — High-level `LicenseManager` class that bundles a public-key registry, an optional signing key, default `signingKeyId` /…
- [otel](./src/otel/instruments.md) — OpenTelemetry instrumentation for the license package. Registers a single `@opentelemetry/api` meter (`@suaveplan/licen…
- [instruments](./src/otel/instruments.md) — OpenTelemetry instrumentation for the license package. Registers a single `@opentelemetry/api` meter (`@suaveplan/licen…
- [revocation-store](./src/revocation/revocation-store/revocation-store.md) — Pluggable revocation blocklist for the license package. The `RevocationStore` interface defines three methods (`add`, `…
- [revocation-store](./src/revocation/revocation-store/revocation-store.md) — Pluggable revocation blocklist for the license package. The `RevocationStore` interface defines three methods (`add`, `…
- [schemas](./src/schemas/schemas.md) — Zod schemas for every public input boundary in the license package. `generateLicense`, `validateLicense`, `createPayloa…
- [schemas](./src/schemas/schemas.md) — Zod schemas for every public input boundary in the license package. `generateLicense`, `validateLicense`, `createPayloa…
- [time](./src/time/time.md) — Time-encoding helpers for the four `TimeEncodingType` variants. The 72-bit payload encodes time in 18 bits with an addi…
- [time](./src/time/time.md) — Time-encoding helpers for the four `TimeEncodingType` variants. The 72-bit payload encodes time in 18 bits with an addi…
- [types](./src/types/license.md) — Public type surface for the license package, ported verbatim from the upstream `index.types.ts` at the wire-format leve…
- [license](./src/types/license.md) — Public type surface for the license package, ported verbatim from the upstream `index.types.ts` at the wire-format leve…
- [validate](./src/validate/validate.md) — License-key validation: parse, verify signature, check version, check expiration, surface every failure as a typed enum…
- [validate](./src/validate/validate.md) — License-key validation: parse, verify signature, check version, check expiration, surface every failure as a typed enum…
- [error-codes](./src/error-codes.md) — This module owns the stable error-code registry for `@suaveplan/license`. It exposes `LICENSE_CODES` (a frozen `defineC…
<!-- end:auto-docs -->
## Highlights
- **72-bit bitpacked payload** (version + signing-key id + product id
- tier + flags + time encoding + time value + feature mask + nonce)
encoded as 9 bytes.
- **Truncated 20-byte signature by default** keeps keys ~50-60
characters; opt into 64-byte full signatures with
`useFullSignature: true`.
- **Crockford-style Base32 alphabet** (no `I`/`L`/`O`/`U` — paste-
friendly).
- **Signature-derived obfuscation pass** rotates the first 15 payload
characters by per-license offsets drawn from the signature.
- **Four time-encoding strategies**: `NONE`, `ABSOLUTE_DAYS`,
`RELATIVE_DAYS`, `ABSOLUTE_TIMESTAMP`.
- **19-bit feature mask** + 5-bit flag bitfield (TRIAL, UPGRADE,
MACHINE_LOCKED, SUBSCRIPTION, EDUCATIONAL).
- **Enterprise extras**: typed `LicenseError` registry, OTel
counters/histograms, Zod boundary validation, injectable `Clock` /
`Rng`, `AbortSignal` propagation, pluggable `RevocationStore`.
## Quick-start
```ts
import { secp256k1 } from "@suaveplan/crypto-curve";
import { LicenseManager, PackageTier } from "@suaveplan/license";
// 1. Generate a signing key (typically done once, off-line)
const secret = secp256k1.randomPrivateKey();
const publicKey = { p: secp256k1.getPublicKey(secret) };
// 2. Bind the manager to that key
const m = new LicenseManager({
signingKey: { s: secret, p: publicKey.p },
publicKeys: { 0: publicKey },
});
// 3. Mint a license key
const { key } = await m.generate({
productId: 5,
tier: PackageTier.PRO,
expirationDays: 365,
});
// 4. Hand the key to the user, validate it on incoming requests
const v = await m.validate(key);
if (v.success) {
console.log(`Tier ${v.details.packageTier}, expires ${v.details.expirationDate}`);
}
```
## Modules
Every feature module is a tree-shakeable subpath export:
| Module | Subpath | Docs |
|---|---|---|
| Error codes | `./error-codes/error-codes` | [src/error-codes.md](src/error-codes.md) |
| Constants | `./constants/constants` | [src/constants/constants.md](src/constants/constants.md) |
| Types + enums | `./types/license` | [src/types/license.md](src/types/license.md) |
| Base32 codec | `./codec/base32/base32` | [src/codec/base32/base32.md](src/codec/base32/base32.md) |
| Bitpack | `./codec/bitpack/bitpack` | [src/codec/bitpack/bitpack.md](src/codec/bitpack/bitpack.md) |
| Payload codec | `./codec/payload/payload` | [src/codec/payload/payload.md](src/codec/payload/payload.md) |
| Obfuscation | `./codec/obfuscation/obfuscation` | [src/codec/obfuscation/obfuscation.md](src/codec/obfuscation/obfuscation.md) |
| Time helpers | `./time/time` | [src/time/time.md](src/time/time.md) |
| Flag helpers | `./flags/flags` | [src/flags/flags.md](src/flags/flags.md) |
| Zod schemas | `./schemas/schemas` | [src/schemas/schemas.md](src/schemas/schemas.md) |
| OTel instruments | `./otel/instruments` | [src/otel/instruments.md](src/otel/instruments.md) |
| Generate | `./generate/generate` | [src/generate/generate.md](src/generate/generate.md) |
| Validate | `./validate/validate` | [src/validate/validate.md](src/validate/validate.md) |
| Manager | `./manager/manager` | [src/manager/manager.md](src/manager/manager.md) |
| Revocation | `./revocation/revocation-store/revocation-store` | [src/revocation/revocation-store/revocation-store.md](src/revocation/revocation-store/revocation-store.md) |
The package root (`.`) re-exports every public symbol from each module
for ergonomic single-import usage.
## Benchmarks
Captured with `mitata` on Intel(R) Xeon(R) CPU E5-2696 v4 @ 2.20GHz,
Bun 1.3.14 (Linux x64). Run yourself with
`bun src/__benchmarks__/license.bench.ts`.
| Benchmark | Mean | p75 | p99 |
|---|---|---|---|
| `base32Encode` (73 bytes) | 38.09 µs | 42.82 µs | 134.18 µs |
| `base32Decode` (73 bytes) | 65.06 µs | 70.70 µs | 189.76 µs |
| `packPayload` | 733.58 ns | 744.46 ns | 1.37 µs |
| `unpackPayload` | 1.16 µs | 1.18 µs | 1.75 µs |
| `generateLicense` (truncated) | 67.64 µs | 73.84 µs | 242.36 µs |
| `validateLicense` (truncated) | 40.52 µs | 42.73 µs | 116.04 µs |
A full generate + validate round-trip completes in ~108 µs on this
hardware, comfortably below the 1 ms p99 budget targeted by the
upstream enterprise audit.
## Revocation
Revocation is an opt-in pluggable layer. Wire any store implementing
the `RevocationStore` interface (sync or async); the package ships a
synchronous in-memory store with optional LRU eviction.
```ts
import {
createMemoryRevocationStore,
fingerprintLicense,
LicenseManager,
} from "@suaveplan/license";
const store = createMemoryRevocationStore({ maxEntries: 100_000 });
const m = new LicenseManager({ signingKey, publicKeys, revocationStore: store });
// Block a compromised key
await m.revoke(fingerprintLicense(compromisedKey));
// Subsequent validates of that key return { error: 'REVOKED' }
```
## Wire format
The on-the-wire layout is unchanged from upstream `@suaveplan/license`.
Each 9-byte payload packs nine fields MSB-first:
```text
| v(3) | kid(5) | pid(6) | tier(4) | flg(5) | te(2) | timeValue(18) | featureMask(19) | nonce(10) |
total = 72 bits
```
A complete key encodes `payload || signature` (29 bytes with truncated
signature, 73 bytes with full signature) as Base32, dashes inserted
every 5 chars for legibility, then runs the signature-derived
obfuscation pass over the first 15 payload chars. The
seed-from-key-region trick (chars 16-30 carry the obfuscation seed)
breaks the circular dependency between "decode needs the seed" and
"the seed comes from a decoded signature".
## License
MIT.

View File

@@ -0,0 +1,74 @@
# `@suaveplan/license` — codec/base32
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Crockford-style Base32 codec used to serialise the packed payload and
signature into a printable license-key string. The alphabet
(`0-9 A-Z` minus `I`, `L`, `O`, `U`) is shared with upstream so any key
generated by upstream parses under suaveplan and vice versa. The encoder
inserts `-` separators every 5 characters for legibility; the decoder
ignores them.
## Features
- 32-character Crockford alphabet, omitting `I`/`L`/`O`/`U`.
- Dash-grouped output, `XXXXX-XXXXX-...`.
- Case-insensitive decode (uppercase canonical).
- Round-trips arbitrary `Uint8Array` data.
- Raises `LicenseError(LICENSE.INVALID_ENCODING)` on any non-alphabet
character, with the offending character in `error.context.character`.
## Usage: Basic
```ts
import { base32Decode, base32Encode } from "@suaveplan/license/codec/base32/base32";
const encoded = base32Encode(new Uint8Array([1, 2, 3, 4, 5]));
// e.g. "0426-1850-A"
const decoded = base32Decode(encoded);
```
## Usage: Advanced
Catching invalid input:
```ts
import { LicenseError } from "@suaveplan/license";
import { base32Decode } from "@suaveplan/license/codec/base32/base32";
try {
base32Decode("INVALID-CHAR-!");
} catch (e) {
if (e instanceof LicenseError && e.code === "LICENSE.INVALID_ENCODING") {
console.warn("Bad character:", e.context?.character);
}
}
```
## API Reference
### `base32Encode(buf: Uint8Array): string`
Returns the Base32 encoding of `buf`, grouped into 5-character chunks
separated by `-`. Empty input returns the empty string.
### `base32Decode(str: string): Uint8Array`
Strips `-`, uppercases, and decodes the resulting characters. Throws
`LicenseError(LICENSE.INVALID_ENCODING)` for any unknown character.
Input is trimmed to a byte boundary — trailing partial-byte bits are
discarded (matching upstream behaviour).
## Implementation Notes
The implementation uses string concatenation of binary digits rather
than bit shifts because the 5-bit-to-8-bit boundary makes manual
shifting verbose; the perf cost is negligible for license-key-sized
inputs (≤73 bytes). For larger inputs, prefer
`@suaveplan/crypto-utils`' base32 implementation. The trailing-bit
truncation in `base32Decode` matches upstream's `Math.floor(bits / 8)`
so identical inputs decode to identical bytes across both codebases.

View File

@@ -0,0 +1,90 @@
# `@suaveplan/license` — codec/bitpack
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Bit-precise pack/unpack primitives used to serialise the 72-bit license
payload into 9 bytes. Plain `DataView` / typed-array slicing can only
write whole bytes, so we walk the buffer bit-by-bit. Two thin classes
hold the cursor: `SimpleBitPacker` writes MSB-first, `SimpleBitUnpacker`
reads MSB-first. Both round-trip identically to upstream's
`@suaveplan/license` bitpacker.
## Features
- `packUint(value, bits)` — writes an unsigned integer in `[0, 2^bits)`
using `bits` bits; raises `LicenseError(LICENSE.PAYLOAD_OUT_OF_RANGE)`
when the value exceeds the declared width or is negative.
- `unpackUint(bits)` — reads the next `bits`-wide unsigned integer;
raises `LicenseError(LICENSE.MALFORMED)` when the read would underflow
the buffer.
- `getBytes()` / `getBitCount()` / `getBitPosition()` introspection
helpers used by the spec-conformance tests.
## Usage: Basic
```ts
import {
SimpleBitPacker,
SimpleBitUnpacker,
} from "@suaveplan/license/codec/bitpack/bitpack";
const p = new SimpleBitPacker();
p.packUint(2, 3); // 3-bit version
p.packUint(17, 5); // 5-bit signingKeyId
const bytes = p.getBytes();
const u = new SimpleBitUnpacker(bytes);
const version = u.unpackUint(3); // 2
const keyId = u.unpackUint(5); // 17
```
## Usage: Advanced
Round-tripping a full 72-bit payload:
```ts
import { FIELD_BITS } from "@suaveplan/license/constants/constants";
import {
SimpleBitPacker,
SimpleBitUnpacker,
} from "@suaveplan/license/codec/bitpack/bitpack";
const p = new SimpleBitPacker();
for (const [_field, bits] of Object.entries(FIELD_BITS)) {
p.packUint(0, bits);
}
const bytes = p.getBytes(); // 9 bytes
const u = new SimpleBitUnpacker(bytes);
for (const [_field, bits] of Object.entries(FIELD_BITS)) {
u.unpackUint(bits); // === 0
}
```
## API Reference
### `class SimpleBitPacker`
- `packUint(value: number, bits: number): void` — write an unsigned
integer; throws on overflow / negative.
- `getBytes(): Uint8Array` — snapshot the packed bytes.
- `getBitCount(): number` — total bits written.
### `class SimpleBitUnpacker`
- `constructor(buffer: Uint8Array)` — wrap a byte buffer.
- `unpackUint(bits: number): number` — read the next integer; throws
`MALFORMED` on underflow.
- `getBitPosition(): number` — current read cursor.
## Implementation Notes
The packer grows its internal `number[]` buffer one byte at a time
because the upstream layout is small (9 bytes) and pre-sizing would
require knowing the total bit count in advance. The `1 << bits` overflow
check is safe for the bit widths used by the license schema (≤19 bits);
for wider integers, callers should use a dedicated bigint bitpacker.

View File

@@ -0,0 +1,98 @@
# `@suaveplan/license` — codec/obfuscation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Per-key character obfuscation pass run after Base32 encoding. The first
15 non-dash characters of the encoded key (which carry the bitpacked
payload) are rotated within the Base32 alphabet by per-character offsets
drawn from the license's signature. Two different licenses with
different signatures produce two completely different obfuscation
patterns, so attackers cannot pattern-match payload structure across
keys.
The seed used to drive the rotation is derived from the unobfuscated
chars 16-30 of the key (the signature region), which breaks the
circular "decode needs the seed, the seed comes from the decode"
dependency: the seed can be recovered from the obfuscated key alone.
## Features
- `obfuscateLicenseKey` / `deobfuscateLicenseKey` round-trip a key
given the same seed.
- `extractSignatureFromKey` recovers the seed from any obfuscated key
in O(n) without touching the bitpacker.
- Non-Base32 characters (dashes, accidental whitespace) pass through
unchanged so the dash-grouped formatting survives obfuscation.
- Short-key passthrough: keys with fewer than 15 non-dash characters
return unmodified, matching upstream's "no signature region to seed
from" fallback.
## Usage: Basic
```ts
import {
deobfuscateLicenseKey,
extractSignatureFromKey,
obfuscateLicenseKey,
} from "@suaveplan/license/codec/obfuscation/obfuscation";
const rawKey = "0426-1850-A8BC-NPQR-..."; // Base32 of payload || signature
const seed = extractSignatureFromKey(rawKey);
const obfuscated = obfuscateLicenseKey(rawKey, seed);
const original = deobfuscateLicenseKey(obfuscated, seed);
```
## Usage: Advanced
Round-tripping with the exact scheme `generateLicense` uses:
```ts
import {
deobfuscateLicenseKey,
extractSignatureFromKey,
obfuscateLicenseKey,
} from "@suaveplan/license/codec/obfuscation/obfuscation";
function obfuscate(rawKey: string): string {
const seed = extractSignatureFromKey(rawKey);
return obfuscateLicenseKey(rawKey, seed);
}
function deobfuscate(obfKey: string): string {
const seed = extractSignatureFromKey(obfKey);
return deobfuscateLicenseKey(obfKey, seed);
}
```
## API Reference
### `obfuscateLicenseKey(licenseKey: string, signatureBytes: Uint8Array): string`
Rotate the first 15 non-dash characters of `licenseKey` within
`BASE32_ALPHABET`. Per-char offsets cycle through `signatureBytes`.
Returns the input unchanged if it has fewer than 15 non-dash chars.
### `deobfuscateLicenseKey(obfuscatedKey: string, signatureBytes: Uint8Array): string`
Inverse of `obfuscateLicenseKey` — rotates by the negation of each
offset.
### `extractSignatureFromKey(obfuscatedKey: string): Uint8Array`
Recover up to 15 seed bytes from chars 16-30 of the obfuscated key by
reading their Base32-alphabet indices (0-31). Returns an empty array
when the key has no signature region.
## Implementation Notes
The rotation arithmetic uses double-modulo (`((x % len) + len) % len`)
so negative offsets (from `deobfuscateLicenseKey`) wrap correctly.
Non-Base32 characters return early in `rotateChar`, leaving dashes and
any defensive whitespace intact — this matches upstream's
permissive-pass behaviour and means an externally re-formatted key
(e.g. paste with extra spaces) still deobfuscates correctly once the
spaces are stripped by the Base32 decoder.

View File

@@ -0,0 +1,89 @@
# `@suaveplan/license` — codec/payload
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`packPayload` and `unpackPayload` are the 72-bit payload codec on top of
the bitpack primitives. They emit exactly 9 bytes (the binary contract
shared with upstream `@suaveplan/license`) and walk the 9 schema fields
in the canonical order declared by `FIELD_BITS`.
`packPayload` raises `LicenseError(LICENSE.PAYLOAD_OUT_OF_RANGE)` when a
field exceeds its declared bit width (via the underlying bitpacker).
`unpackPayload` raises `LicenseError(LICENSE.MALFORMED)` for any input
length other than `PAYLOAD_BYTES` (9).
## Features
- Bit-for-bit wire compatibility with upstream's `packPayload` /
`unpackPayload`.
- Field-bound validation via `SimpleBitPacker.packUint`, surfaced as
typed `LicenseError`s.
- Strict 9-byte length invariant on both sides — no implicit truncation
or padding.
- Returns a fresh `Uint8Array`; safe to mutate the caller's buffer.
## Usage: Basic
```ts
import {
packPayload,
unpackPayload,
} from "@suaveplan/license/codec/payload/payload";
import { PackageTier, TimeEncodingType } from "@suaveplan/license/types/license";
const bytes = packPayload({
version: 2,
signingKeyId: 0,
productId: 1,
packageTier: PackageTier.PRO,
flags: 0,
timeEncodingType: TimeEncodingType.NONE,
timeValue: 0,
featureMask: 0,
nonce: 0x123,
});
console.log(bytes.length); // 9
const round = unpackPayload(bytes);
```
## Usage: Advanced
Catching a field-overflow at the generation boundary:
```ts
import { LicenseError } from "@suaveplan/license";
import { packPayload } from "@suaveplan/license/codec/payload/payload";
try {
packPayload({ /* ...valid */ productId: 64 } as never); // 6 bits, max 63
} catch (err) {
if (err instanceof LicenseError && err.code === "LICENSE.PAYLOAD_OUT_OF_RANGE") {
// surface to the user as a form-validation failure
}
}
```
## API Reference
### `packPayload(payload: LicensePayload): Uint8Array`
Pack a payload to its 9-byte representation. Throws
`LicenseError(LICENSE.PAYLOAD_OUT_OF_RANGE)` on field overflow.
### `unpackPayload(data: Uint8Array): LicensePayload`
Unpack a 9-byte buffer. Throws `LicenseError(LICENSE.MALFORMED)` for
any other length.
## Implementation Notes
The unpacker casts `timeEncodingType` to the `TimeEncodingType` enum
even though the bit width (2) admits any value in `[0, 3]` — all four
values map to legitimate enum members, so the cast is safe. The
constant `PAYLOAD_BYTES = 9` lives in `constants/constants.ts` so any
future schema change (which would require a `LICENSE_VERSION` bump)
updates a single source of truth.

View File

@@ -0,0 +1,81 @@
# `@suaveplan/license` — constants
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Wire-format constants for the license package. Every value here
is part of the binary contract: the per-field bit widths
(`FIELD_BITS`), the Base32 alphabet, the signature lengths, the version
byte, and the day-to-millisecond conversion factor. Changing any
constant breaks every existing license key, so they live in their own
module and are imported by every code path that touches the format.
## Features
- `LICENSE_VERSION` (currently `2`) — pinned by the 3-bit version field;
`validateLicense` rejects any payload with a higher version.
- `FULL_SIGNATURE_BYTES` (64) / `SIGNATURE_BYTES` (20) /
`MIN_SIGNATURE_BYTES` (16) — the three signature-length thresholds
honoured by generation and validation.
- `BASE32_ALPHABET` — 32-char Crockford-style alphabet (no I/L/O/U) used
by both encode and decode.
- `LICENSE_SCHEMA` — per-field maximum values, used by Zod schemas and
by the bounds checks in `packPayload`.
- `FIELD_BITS` — per-field bit widths summing to 72, consumed by the
bitpacker.
- `PAYLOAD_BYTES` (9), `OBFUSCATION_PAYLOAD_CHARS` (15), and
`MS_PER_DAY` (86 400 000) — derived constants pulled out so callers
don't have to recompute them.
## Usage: Basic
```ts
import { LICENSE_VERSION, BASE32_ALPHABET } from "@suaveplan/license/constants/constants";
console.log(`Targeting wire format v${LICENSE_VERSION}`);
console.log(`Alphabet size: ${BASE32_ALPHABET.length}`); // 32
```
## Usage: Advanced
Bounds-checking a payload field at the application layer before
constructing it:
```ts
import { LICENSE_SCHEMA } from "@suaveplan/license/constants/constants";
import type { LicensePayload } from "@suaveplan/license/types/license";
function clampPayload(p: LicensePayload): LicensePayload {
return {
...p,
productId: Math.min(p.productId, LICENSE_SCHEMA.productId),
timeValue: Math.min(p.timeValue, LICENSE_SCHEMA.timeValue),
};
}
```
## API Reference
| Symbol | Type | Value |
|---|---|---|
| `LICENSE_VERSION` | `2` | Current format version |
| `FULL_SIGNATURE_BYTES` | `64` | secp256k1 compact signature length |
| `SIGNATURE_BYTES` | `20` | Default truncated-signature length |
| `MIN_SIGNATURE_BYTES` | `16` | Lowest acceptable truncation |
| `BASE32_ALPHABET` | `string` | `0-9A-HJKMNP-TV-Z` (32 chars) |
| `LICENSE_SCHEMA` | `Readonly<Record<field, max>>` | Per-field max values |
| `FIELD_BITS` | `Readonly<Record<field, bits>>` | Per-field bit widths |
| `PAYLOAD_BYTES` | `9` | Packed payload size |
| `OBFUSCATION_PAYLOAD_CHARS` | `15` | Chars rotated by obfuscation |
| `MS_PER_DAY` | `86_400_000` | UTC day in milliseconds |
## Implementation Notes
Every constant is declared `as const`, so its TypeScript type is the
literal value (e.g. `LICENSE_VERSION: 2`), giving exhaustiveness benefits
when compared in `switch` statements. The two records (`LICENSE_SCHEMA`
and `FIELD_BITS`) are also frozen at the type level via `as const`,
preventing accidental mutation through their `Readonly` view.

View File

@@ -0,0 +1,99 @@
# `@suaveplan/license` — error-codes
## Purpose
This module owns the stable error-code registry for `@suaveplan/license`. It
exposes `LICENSE_CODES` (a frozen `defineCodes("LICENSE", [...])` record),
the `LicenseErrorCode` type union, and the `LicenseError` class. Every
public failure path in the package raises a `LicenseError` carrying one of
these twelve namespaced `LICENSE.*` codes, so consumers can dispatch on the
literal string or `instanceof LicenseError` and let TypeScript narrow the
union exhaustively.
## Features
- 12 codes covering input validation, encoding / parsing, cryptographic
failures, lifecycle (expired / revoked), manager state, feature-mask
bounds, and `AbortSignal` cancellation.
- `LicenseError` extends `SuaveplanError<LicenseErrorCode>` so the
generic `code` type narrows under `instanceof`.
- Constructor widened to `public` so all modules can construct errors
directly without trampolines.
- Codes are namespaced `LICENSE.*` per the suaveplan-wide pattern (see
`@suaveplan/crypto-keys`'s `CHAIN_KEYS.*`), making programmatic dispatch
consistent across packages.
## Usage: Basic
```ts
import { LicenseError, LICENSE_CODES } from "@suaveplan/license/error-codes/error-codes";
throw new LicenseError(
LICENSE_CODES.INVALID_ENCODING,
"Invalid Base32 character: !"
);
```
## Usage: Advanced
Attach a Zod `cause` and structured `context` for an input-validation
boundary failure:
```ts
import { LicenseError, LICENSE_CODES } from "@suaveplan/license/error-codes/error-codes";
import { LicensePayloadSchema } from "@suaveplan/license/schemas/schemas";
try {
LicensePayloadSchema.parse(input);
} catch (zodError) {
throw new LicenseError(
LICENSE_CODES.INVALID_INPUT,
"License payload failed schema validation",
{ cause: zodError, context: { source: "generateLicense" } }
);
}
```
Consumer-side narrowing:
```ts
import { LicenseError } from "@suaveplan/license";
try {
await manager.generate({ productId: 1 });
} catch (error) {
if (error instanceof LicenseError && error.code === "LICENSE.NO_SIGNING_KEY") {
// recover or re-prompt
} else {
throw error;
}
}
```
## API Reference
### `LICENSE_CODES`
Frozen `Readonly<Record<Reason, "LICENSE.<Reason>">>` where `Reason` is
one of the 12 keys: `INVALID_INPUT`, `INVALID_ENCODING`, `MALFORMED`,
`UNSUPPORTED_VERSION`, `UNKNOWN_SIGNING_KEY`, `INVALID_SIGNATURE`,
`EXPIRED`, `REVOKED`, `NO_SIGNING_KEY`, `FEATURE_INDEX_OUT_OF_RANGE`,
`PAYLOAD_OUT_OF_RANGE`, `ABORTED`.
### `type LicenseErrorCode`
String-literal union of all 12 namespaced codes.
### `class LicenseError extends SuaveplanError<LicenseErrorCode>`
Concrete error class. Constructor: `(code, message, options?)` where
`options` is `SuaveplanErrorOptions` (carries optional `cause`,
`context`, `traceId`, `clock`).
## Implementation Notes
The registry uses `defineCodes` from `@suaveplan/error` so the type of
`LICENSE_CODES.INVALID_ENCODING` is the literal `"LICENSE.INVALID_ENCODING"`
(not the broad `string`), giving exhaustiveness checking in `switch`
statements. The list is declared `as const` so TypeScript captures the
tuple shape and the helper's mapped-type return picks every key.

View File

@@ -0,0 +1,112 @@
# `@suaveplan/license` — flags
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Bitmask helpers for the 5-bit `flags` field (`LicenseFlags`) and the
19-bit `featureMask` field. The flags field stores up to 5 boolean
license states (`TRIAL`, `UPGRADE`, `MACHINE_LOCKED`, `SUBSCRIPTION`,
`EDUCATIONAL`); the feature mask stores up to 19 product-specific
feature toggles. This module gives type-safe accessors for both: the
flag helpers are typed against the `LicenseFlags` enum; the feature
helpers validate that the supplied index is within `[0, 18]` and raise
`LicenseError(LICENSE.FEATURE_INDEX_OUT_OF_RANGE)` otherwise.
## Features
- `hasFlag` / `combineFlags` — flag-bit accessors typed against the
`LicenseFlags` enum so misuses surface at compile time.
- `hasFeature` / `combineFeatures` / `getEnabledFeatures` — feature-bit
accessors with runtime index-range checks.
- Out-of-range indices throw a typed `LicenseError`, never a generic
`Error` — callers can `instanceof LicenseError` and dispatch on
`error.code`.
- `getEnabledFeatures` walks only the 19 declared bits, so callers
feeding in stray high bits get a clean answer.
## Usage: Basic
```ts
import {
combineFlags,
hasFlag,
hasFeature,
} from "@suaveplan/license/flags/flags";
import { LicenseFlags } from "@suaveplan/license/types/license";
const flags = combineFlags(LicenseFlags.TRIAL, LicenseFlags.SUBSCRIPTION);
hasFlag(flags, LicenseFlags.TRIAL); // true
hasFeature(0b1010, 1); // true
```
## Usage: Advanced
Building a payload-shaped feature mask from a domain-specific feature
enum, with overflow guard:
```ts
import {
combineFeatures,
getEnabledFeatures,
} from "@suaveplan/license/flags/flags";
import { LicenseError } from "@suaveplan/license";
const enum Feature {
Export = 0,
BulkOps = 1,
AdvancedSearch = 4,
AiAssistant = 18,
}
try {
const mask = combineFeatures(
Feature.Export,
Feature.BulkOps,
Feature.AdvancedSearch,
Feature.AiAssistant
);
const enabled = getEnabledFeatures(mask); // [0, 1, 4, 18]
} catch (e) {
if (e instanceof LicenseError && e.code === "LICENSE.FEATURE_INDEX_OUT_OF_RANGE") {
// upstream produced an out-of-range index
}
}
```
## API Reference
### `hasFlag(flags: number, flag: LicenseFlags): boolean`
Test whether `flag` is set in `flags`.
### `combineFlags(...flags: LicenseFlags[]): number`
OR-merge multiple flags into a single bitmask.
### `hasFeature(featureMask: number, featureIndex: number): boolean`
Test whether feature index `featureIndex` is set. Throws
`LicenseError(LICENSE.FEATURE_INDEX_OUT_OF_RANGE)` when `featureIndex`
is outside `[0, 18]`.
### `combineFeatures(...features: number[]): number`
OR-merge multiple feature indices into a single bitmask. Same range
check as `hasFeature`.
### `getEnabledFeatures(featureMask: number): number[]`
Enumerate every enabled feature index in ascending order. Bits past
index 18 are ignored (consistent with the 19-bit field width).
## Implementation Notes
The 19-feature ceiling matches the 19-bit `featureMask` field in the
license payload. The shared `ensureFeatureIndexInRange` helper means
the two range-checking call sites stay in lockstep; if the bit budget
ever changes, only one constant needs to move. The flag enum uses
power-of-two values (`1 << 0`, `1 << 1`, ...) so `combineFlags` is a
straight `reduce(|)`.

View File

@@ -0,0 +1,129 @@
# `@suaveplan/license` — generate
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Top-level license-key generation, payload construction, and parse-only
inspection. This is the place that brings together every lower module:
it packs the payload, signs the packed bytes with secp256k1 (via
`@suaveplan/crypto-curve`) over a SHA-256 digest, optionally truncates
the signature, Base32-encodes `payload || signature`, applies the
signature-derived obfuscation pass, and emits OpenTelemetry counters
and span attributes — all behind a single async function.
`createPayload` is the quick-builder: callers supply a partial
`QuickLicenseOptions` bag, and the function fills sensible defaults
(version, signingKeyId, tier, etc.) and computes the time-encoding
fields from `expirationDays` / `useAbsoluteTime`. The `Rng` for the
nonce and the `Clock` for absolute-day expirations are both injectable
so tests run deterministically.
`parseLicense` is the inverse of generation up to (but not including)
signature verification — it deobfuscates, Base32-decodes, and unpacks
the payload, returning the three byte slices plus the decoded record.
It does NOT verify the signature or check expiration; for that, use
`validateLicense`.
## Features
- `generateLicense` — full generation pipeline with Zod boundary
validation, OTel emission, and `AbortSignal` propagation.
- `createPayload` — quick-builder with injectable `Rng` / `Clock` and
Zod-validated input.
- `parseLicense` — pure decode-and-unpack, returning the byte slices
plus the decoded payload.
- Wire-format bit-identical to upstream `@suaveplan/license`.
## Usage: Basic
```ts
import { secp256k1 } from "@suaveplan/crypto-curve";
import {
createPayload,
generateLicense,
} from "@suaveplan/license/generate/generate";
const s = secp256k1.randomPrivateKey();
const result = await generateLicense(
createPayload({ productId: 5, expirationDays: 365 }),
{ signingKey: { s }, useFullSignature: false }
);
console.log(result.key); // "XYZ12-ABCDE-..."
```
## Usage: Advanced
Deterministic generation with `Rng` injection, `AbortSignal`, and
truncated signature for a compact key:
```ts
import { secp256k1 } from "@suaveplan/crypto-curve";
import type { Rng } from "@suaveplan/types";
import { LicenseError } from "@suaveplan/license";
import {
createPayload,
generateLicense,
parseLicense,
} from "@suaveplan/license/generate/generate";
const rng: Rng = { nextUint32: () => 0x123, nextFloat: () => 0 };
const payload = createPayload(
{ productId: 7, expirationDays: 90 },
{ rng }
);
const ctrl = new AbortController();
try {
const r = await generateLicense(payload, {
signingKey: { s: secp256k1.randomPrivateKey() },
useFullSignature: false,
signal: ctrl.signal,
});
const parsed = parseLicense(r.key);
console.log("payload bytes:", parsed.payloadBytes.length); // 9
console.log("signature bytes:", parsed.signatureBytes.length); // 20
} catch (e) {
if (e instanceof LicenseError && e.code === "LICENSE.ABORTED") {
console.log("cancelled");
}
}
```
## API Reference
### `generateLicense(payload, options): Promise<LicenseGenerationResult>`
Pack + sign + encode + obfuscate. Options: `signingKey` (required),
`signingKeyIdOverride?`, `useFullSignature?` (default `true` at the
function level; `LicenseManager` overrides to `false`), `signal?`,
`rng?` (forwarded to `createPayload` when this function is invoked from
the manager). Throws `LicenseError(LICENSE.INVALID_INPUT)` on schema
failure, `LicenseError(LICENSE.ABORTED)` on a pre-aborted signal.
### `createPayload(options?, injection?): LicensePayload`
Quick-builder; defaults: `productId=1`, `tier=PackageTier.BASIC`,
`expirationDays=0` (perpetual), `flags=0`, `features=0`,
`useAbsoluteTime=false`. `injection.rng` drives the 10-bit nonce;
`injection.clock` drives `getDayNumber` when `useAbsoluteTime`.
### `parseLicense(licenseKey): LicenseParsedResult`
Deobfuscate + Base32-decode + slice + unpack. Throws
`LicenseError(LICENSE.MALFORMED)` when the decoded byte length is
less than `PAYLOAD_BYTES + 1`, or when the Base32 decode surfaces an
`INVALID_ENCODING`.
## Implementation Notes
The signature scheme is upstream-compatible: SHA-256 over the 9-byte
payload, then secp256k1 sign over the digest. The truncated path
slices the first 20 bytes of the resulting compact signature; the full
path keeps all 64. This means a key generated by upstream verifies
under suaveplan and vice versa as long as the same SignKey/PublicKey
byte material is used. The `Rng` default uses `crypto.getRandomValues`
to build a 32-bit unsigned integer, which is then masked to the 10-bit
nonce by the caller.

View File

@@ -0,0 +1,131 @@
# `@suaveplan/license` — manager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
High-level `LicenseManager` class that bundles a public-key registry,
an optional signing key, default `signingKeyId` / `productId`,
injectable `Clock` / `Rng`, and an optional `RevocationStore` into a
single object. Most callers will use only the manager — the lower-level
`generateLicense` / `validateLicense` functions are exposed for the
rare case where the caller wants stateless calls.
`LicenseManager.generate` defaults `useFullSignature: false` (compact
keys) to match upstream's caller-side default; passing
`{ useFullSignature: true }` opts into 64-byte signatures. `revoke` and
`isRevoked` reach into the configured `RevocationStore` (lazily
allocating an in-memory store on first `revoke` call if none was
configured).
## Features
- 11 methods covering the full lifecycle: `addPublicKey`,
`setSigningKey`, `setRevocationStore`, `generate`, `generateTrial`,
`validate`, `isValid`, `parse`, `revoke`, `isRevoked` (+ constructor).
- Quick-trial helper `generateTrial({ expirationDays, productId?,
tier?, features? })` that sets the `TRIAL` flag and uses
`RELATIVE_DAYS` time encoding.
- Injectable `Clock` is used both as the default `referenceDate` for
`validate` and as the time base for `createPayload` absolute-day
computations.
- Injectable `Rng` flows through to `createPayload` for deterministic
nonces.
- Optional `RevocationStore` consulted before signature verification.
- Throws `LicenseError(LICENSE.NO_SIGNING_KEY)` when `generate` is
called without a configured signing key.
## Usage: Basic
```ts
import { secp256k1 } from "@suaveplan/crypto-curve";
import { LicenseManager, PackageTier } from "@suaveplan/license";
const s = secp256k1.randomPrivateKey();
const p = secp256k1.getPublicKey(s);
const m = new LicenseManager({
signingKey: { s, p },
publicKeys: { 0: { p } },
});
const r = await m.generate({ productId: 5, tier: PackageTier.PRO });
const v = await m.validate(r.key); // { success: true, details: { ... } }
```
## Usage: Advanced
A manager with deterministic test injection, a revocation store, and a
multi-key registry:
```ts
import { secp256k1 } from "@suaveplan/crypto-curve";
import type { Clock, Rng } from "@suaveplan/types";
import {
createMemoryRevocationStore,
fingerprintLicense,
LicenseManager,
} from "@suaveplan/license";
const kp1 = (() => {
const s = secp256k1.randomPrivateKey();
return { s, p: secp256k1.getPublicKey(s) };
})();
const kp2 = (() => {
const s = secp256k1.randomPrivateKey();
return { s, p: secp256k1.getPublicKey(s) };
})();
const clock: Clock = () => Date.UTC(2026, 5, 17, 12, 0, 0);
const rng: Rng = { nextUint32: () => 0xdead, nextFloat: () => 0 };
const store = createMemoryRevocationStore({ maxEntries: 10_000 });
const m = new LicenseManager({
signingKey: { s: kp1.s },
publicKeys: { 0: { p: kp1.p }, 1: { p: kp2.p } },
defaultSigningKeyId: 0,
defaultProductId: 5,
clock,
rng,
revocationStore: store,
});
const trial = await m.generateTrial({ expirationDays: 14 });
await m.revoke(fingerprintLicense(trial.key));
const v = await m.validate(trial.key);
// → { success: false, error: 'REVOKED', message: 'License has been revoked' }
```
## API Reference
### `class LicenseManager`
| Method | Returns | Notes |
|---|---|---|
| `constructor(config?)` | `LicenseManager` | All `config` fields optional |
| `addPublicKey(keyId, publicKey)` | `void` | Idempotent overwrite |
| `setSigningKey(signingKey, keyId?)` | `void` | Updates default signing key id when supplied |
| `setRevocationStore(store)` | `void` | Replaces an existing store |
| `generate(options, genOptions?)` | `Promise<LicenseGenerationResult>` | Throws `NO_SIGNING_KEY` if unconfigured |
| `generateTrial(opts)` | `Promise<LicenseGenerationResult>` | Sets `TRIAL` flag, `RELATIVE_DAYS` encoding |
| `validate(licenseKey, options?)` | `Promise<LicenseValidationResult>` | Consults revocation store first |
| `isValid(licenseKey)` | `Promise<boolean>` | Wraps `validate` |
| `parse(licenseKey)` | `LicenseParsedResult` | No verification |
| `revoke(fingerprint)` | `Promise<void>` | Lazily allocates store if none |
| `isRevoked(fingerprint)` | `Promise<boolean>` | Returns `false` if no store |
### `interface GenerateTrialOptions`
`{ expirationDays: number, productId?: number, tier?: PackageTier, features?: number }`.
## Implementation Notes
The lazy-allocation behaviour of `revoke` (auto-creates an in-memory
store on first call) means simple consumers can use revocation without
constructing a store explicitly, while still letting power users plug
in a custom backend. The manager's `clock` injection drives BOTH the
generation-time `getDayNumber` (for absolute-day expirations) AND the
validation-time `referenceDate` (when none is supplied) — keeping the
two sides on the same simulated time line for deterministic tests.

View File

@@ -0,0 +1,89 @@
# `@suaveplan/license` — otel/instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
OpenTelemetry instrumentation for the license package. Registers a
single `@opentelemetry/api` meter (`@suaveplan/license@0.1.0`) and
attaches three instruments to it: a generation counter, a validation
counter (attribute `license.valid: boolean`), and a validation-duration
histogram (unit `ms`). When no SDK is wired the meter falls back to the
Noop implementation, so importing this module has zero side effects in
production unless the host application registers a real provider.
## Features
- `license.generate_total` — counter incremented once per successful
`generateLicense` call.
- `license.validate_total` — counter incremented per validation call
with attribute `license.valid: boolean` (and, on failure, `license.error`
carrying the enum value).
- `license.validate_duration_ms` — histogram of validation latency in
milliseconds.
- Five `ATTR_*` constants exported so call sites and tests reference
attribute keys by name, not by stringly-typed literals.
## Usage: Basic
```ts
import {
ATTR_LICENSE_VALID,
licenseValidateTotal,
} from "@suaveplan/license/otel/instruments";
licenseValidateTotal.add(1, { [ATTR_LICENSE_VALID]: true });
```
## Usage: Advanced
Wire an in-memory provider for tests and assert that the instruments
emit:
```ts
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 60_000 });
const provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
// ...exercise the license package...
await provider.forceFlush();
console.log(exporter.getMetrics());
```
## API Reference
### Instruments
- `licenseGenerateTotal: Counter`
- `licenseValidateTotal: Counter`
- `licenseValidateDurationMs: Histogram`
### Attribute keys
- `ATTR_LICENSE_VALID = "license.valid"`
- `ATTR_LICENSE_ERROR = "license.error"`
- `ATTR_LICENSE_TIER = "license.tier"`
- `ATTR_LICENSE_PRODUCT_ID = "license.product_id"`
- `ATTR_LICENSE_SIGNING_KEY_ID = "license.signing_key_id"`
## Implementation Notes
The meter is fetched at module load via `metrics.getMeter(...)`. The
`@opentelemetry/api` library guarantees that subsequent calls with the
same name+version return the same meter instance, so importing
`instruments.ts` from multiple call sites does not create duplicate
counters. The Noop fallback means `licenseGenerateTotal.add(...)` is a
zero-cost call when no SDK is wired — safe to leave the instrumentation
on in every code path.

View File

@@ -0,0 +1,102 @@
# `@suaveplan/license` — revocation/revocation-store
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Pluggable revocation blocklist for the license package. The
`RevocationStore` interface defines three methods (`add`, `has`,
`list`), each of which may return synchronously or as a `Promise` —
that lets callers wire an in-memory store for tests, a Redis-backed
store for production, or a remote API without changing the
`LicenseManager` call sites. A synchronous in-memory implementation
ships with the package; `fingerprintLicense` produces a SHA-256 hex of
an obfuscated key suitable as a store key.
## Features
- `createMemoryRevocationStore(options?)` — synchronous in-memory store
with optional `maxEntries` FIFO eviction cap.
- `fingerprintLicense(licenseKey)` — stable SHA-256 hex of the
(obfuscated) license-key string.
- Insertion-ordered `list()` snapshot for backup / migration.
- Re-adding an existing fingerprint moves it to the end (LRU-on-write)
so frequently-revoked keys survive eviction longest.
## Usage: Basic
```ts
import {
createMemoryRevocationStore,
fingerprintLicense,
} from "@suaveplan/license/revocation/revocation-store/revocation-store";
const store = createMemoryRevocationStore({ maxEntries: 10_000 });
await store.add(fingerprintLicense("XXXXX-YYYYY-..."));
const blocked = await store.has(fingerprintLicense("XXXXX-YYYYY-..."));
```
## Usage: Advanced
Wire a custom async store backed by a database:
```ts
import type { RevocationStore } from "@suaveplan/license";
function createDbRevocationStore(db: SomeDb): RevocationStore {
return {
async add(fingerprint) {
await db.insert("revocations", { fingerprint });
},
async has(fingerprint) {
return await db.exists("revocations", { fingerprint });
},
async list() {
const rows = await db.select("revocations", ["fingerprint"]);
return rows.map((r) => r.fingerprint);
},
};
}
```
## API Reference
### `interface RevocationStore`
```ts
interface RevocationStore {
add(fingerprint: string): void | Promise<void>;
has(fingerprint: string): boolean | Promise<boolean>;
list(): readonly string[] | Promise<readonly string[]>;
}
```
### `interface MemoryRevocationStoreOptions`
```ts
interface MemoryRevocationStoreOptions {
readonly maxEntries?: number;
}
```
### `createMemoryRevocationStore(options?): RevocationStore`
Build a fresh in-memory store. Returns synchronous methods; safe to
`await` regardless.
### `fingerprintLicense(licenseKey: string): string`
SHA-256 hex of the UTF-8 bytes of `licenseKey`.
## Implementation Notes
The in-memory store uses a `Set<string>` because `Set` preserves
insertion order in every modern JS engine and gives O(1) `add`/`has`.
The LRU-on-write semantic (re-add moves to end) matches the typical
"a fingerprint just got revoked again, so keep it" intuition without
needing a separate access timestamp. `fingerprintLicense` uses SHA-256
because the fingerprint is the wire-facing key for the store; using
the obfuscated key directly would make the store quadratic in key
length and leak structure.

View File

@@ -0,0 +1,91 @@
# `@suaveplan/license` — schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Zod schemas for every public input boundary in the license package.
`generateLicense`, `validateLicense`, `createPayload`, and the
`LicenseManager` methods parse their inputs against these schemas at
entry; failed parses raise `LicenseError(LICENSE.INVALID_INPUT)` with
the `ZodError` attached as `cause`. Centralising the schemas here keeps
the runtime modules thin and ensures downstream Zod-aware consumers
(form validators, OpenAPI generators) can reuse the same shapes.
## Features
- `LicensePayloadSchema` — every 9 payload fields bounded by the
per-field maximums in `LICENSE_SCHEMA`.
- `LicenseGenerationOptionsSchema` — wraps `SignKey` (with a `Uint8Array`
secret), the optional `signingKeyIdOverride`, `useFullSignature`,
`signal`, and `rng`.
- `LicenseValidationOptionsSchema` — accepts `publicKeys` as either a
`Map` or a `Record`, mirroring the upstream API.
- `QuickLicenseOptionsSchema` — covers the quick-builder fields with the
same per-field bounds.
- Every schema is `safeParse`-friendly; the runtime callers turn parse
failures into typed `LicenseError`s.
## Usage: Basic
```ts
import { LicensePayloadSchema } from "@suaveplan/license/schemas/schemas";
const parsed = LicensePayloadSchema.parse(input); // throws ZodError on failure
```
## Usage: Advanced
Turn a parse failure into a typed `LicenseError`:
```ts
import { LICENSE_CODES, LicenseError } from "@suaveplan/license";
import { LicensePayloadSchema } from "@suaveplan/license/schemas/schemas";
function parsePayload(input: unknown) {
const r = LicensePayloadSchema.safeParse(input);
if (!r.success) {
throw new LicenseError(
LICENSE_CODES.INVALID_INPUT,
"Invalid license payload",
{ cause: r.error, context: { issues: r.error.issues.length } }
);
}
return r.data;
}
```
## API Reference
### `LicensePayloadSchema`
`z.object({ version, signingKeyId, productId, packageTier, flags,
timeEncodingType, timeValue, featureMask, nonce })` with every field
bounded by `LICENSE_SCHEMA`.
### `LicenseGenerationOptionsSchema`
`z.object({ signingKey, signingKeyIdOverride?, useFullSignature?, signal?,
rng? })`. `signingKey` is `{ s: Uint8Array, p?: Uint8Array }`.
### `LicenseValidationOptionsSchema`
`z.object({ publicKeys, referenceDate?, creationDate?, signal? })`.
`publicKeys` is a `Map` or `Record<number, { p: Uint8Array }>`.
### `QuickLicenseOptionsSchema`
`z.object({ productId?, tier?, expirationDays?, flags?, features?,
useAbsoluteTime? })` with per-field bounds.
## Implementation Notes
`rng` is intentionally typed as `z.unknown().optional()` because the
`Rng` interface from `@suaveplan/types` is structural (a duck-typed bag
of methods); attempting to validate it with a structural Zod schema
would force consumers to surface every optional method even when their
RNG implementation chooses not to. The runtime modules treat `rng` as
opaque and call only the documented `nextUint32` method, so the loose
schema is safe.

View File

@@ -0,0 +1,85 @@
# `@suaveplan/license` — time
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Time-encoding helpers for the four `TimeEncodingType` variants. The
72-bit payload encodes time in 18 bits with an additional 2-bit
discriminator (`NONE` / `ABSOLUTE_DAYS` / `RELATIVE_DAYS` /
`ABSOLUTE_TIMESTAMP`), giving four distinct expiration strategies. This
module turns those numeric fields into concrete `{ isExpired,
expirationDate }` records and lets callers inject a `Clock` (from
`@suaveplan/types`) for deterministic test runs.
## Features
- Four expiration strategies, one switch — no separate code paths per
call site.
- Injectable `Clock` parameter; defaults to `Date.now()` so production
callers don't have to thread it through.
- `getDayNumber` / `dayNumberToDate` round-trip helpers used by
generation when computing absolute-day expirations.
- Default-arm fall-through returns `{ isExpired: false }` for unknown
encodings, matching upstream's permissive behaviour (so a future
encoding variant never bricks an older validator).
## Usage: Basic
```ts
import { checkExpiration } from "@suaveplan/license/time/time";
import { TimeEncodingType } from "@suaveplan/license/types/license";
const r = checkExpiration(TimeEncodingType.NONE, 0);
console.log(r.isExpired); // false (perpetual)
```
## Usage: Advanced
Threading a deterministic `Clock` through to relative-day expiry:
```ts
import type { Clock } from "@suaveplan/types";
import { checkExpiration } from "@suaveplan/license/time/time";
import { TimeEncodingType } from "@suaveplan/license/types/license";
const clock: Clock = () => Date.UTC(2026, 5, 17, 12, 0, 0);
const creation = new Date("2026-06-01T00:00:00Z");
const r = checkExpiration(
TimeEncodingType.RELATIVE_DAYS,
14,
undefined, // use clock
creation,
clock
);
```
## API Reference
### `getDayNumber(date?: Date, clock?: Clock): number`
Days since the Unix epoch. Uses `date.getTime()` when provided, otherwise
the injected `clock` (default `Date.now()`).
### `dayNumberToDate(dayNumber: number): Date`
Days since Unix epoch → `Date`.
### `checkExpiration(timeEncodingType, timeValue, referenceDate?, creationDate?, clock?): { isExpired, expirationDate? }`
Determine expiration for a license. `referenceDate` defaults to
`new Date(clock())`. `creationDate` is consulted only for
`RELATIVE_DAYS` (falls back to `referenceDate`).
## Implementation Notes
The unknown-encoding default arm (`return { isExpired: false }`) is
intentional — it makes adding a new encoding variant a backward-
compatible change for existing validators, which is important because
license keys are long-lived and may outlive the validator that
originally minted them. The `clock` parameter sits last in the
argument list and defaults to `Date.now()`, so the public
`checkExpiration` call site stays compact in normal usage and only
expands when a test needs determinism.

View File

@@ -0,0 +1,121 @@
# `@suaveplan/license` — types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Public type surface for the license package, ported verbatim from the
upstream `index.types.ts` at the wire-format level. This module owns
every interface, type alias, and enum that crosses the package
boundary; the runtime modules import their shapes from here so the
binary contract (72-bit payload layout, signature lengths, validation
error variants) is declared in one place.
Two additive suaveplan extensions sit alongside the upstream surface:
`LicenseValidationError.REVOKED` (additional failure variant) and the
`LicenseManagerConfig.{clock, rng, revocationStore}` injection trio.
Both are opt-in and never affect callers that don't reach for them.
## Features
- Three enums verbatim from upstream: `TimeEncodingType`,
`LicenseFlags`, `PackageTier`.
- `LicensePayload` (the 9-field 72-bit struct), `LicenseDetails`,
`LicenseGenerationOptions`/`Result`, `LicenseValidationOptions`,
`LicenseValidationResult` discriminated union.
- `PublicKey` / `SignKey` shaped to upstream's `{ p, s }` records so the
truncated-signature verification path round-trips bit-identically.
- `RevocationStore` re-export so the package root can serve a single
named entry for downstream consumers.
- `CreatePayloadInjection` bag carrying optional `Clock` and `Rng`,
threaded through to deterministic test runs.
## Usage: Basic
```ts
import type {
LicensePayload,
LicenseValidationResult,
} from "@suaveplan/license/types/license";
import { PackageTier, TimeEncodingType } from "@suaveplan/license/types/license";
const payload: LicensePayload = {
version: 2,
signingKeyId: 0,
productId: 7,
packageTier: PackageTier.PRO,
flags: 0,
timeEncodingType: TimeEncodingType.NONE,
timeValue: 0,
featureMask: 0,
nonce: 0x123,
};
function isExpired(result: LicenseValidationResult): boolean {
return !result.success && result.error === "EXPIRED";
}
```
## Usage: Advanced
Narrowing the discriminated union and switching on every error variant:
```ts
import {
LicenseValidationError,
type LicenseValidationResult,
} from "@suaveplan/license/types/license";
function describe(result: LicenseValidationResult): string {
if (result.success) {
return `Valid (tier ${result.details.packageTier})`;
}
switch (result.error) {
case LicenseValidationError.MALFORMED: return "Malformed key";
case LicenseValidationError.INVALID_ENCODING: return "Invalid encoding";
case LicenseValidationError.INVALID_SIGNATURE: return "Bad signature";
case LicenseValidationError.UNKNOWN_SIGNING_KEY: return "Unknown key";
case LicenseValidationError.EXPIRED: return "Expired";
case LicenseValidationError.UNSUPPORTED_VERSION: return "Wrong version";
case LicenseValidationError.REVOKED: return "Revoked";
default: {
const _exhaustive: never = result.error;
return _exhaustive;
}
}
}
```
## API Reference
### Enums
- `TimeEncodingType` — `NONE` / `ABSOLUTE_DAYS` / `RELATIVE_DAYS` / `ABSOLUTE_TIMESTAMP`.
- `LicenseFlags` — 5-bit bitfield: `TRIAL`, `UPGRADE`, `MACHINE_LOCKED`, `SUBSCRIPTION`, `EDUCATIONAL`.
- `PackageTier` — `FREE` / `BASIC` / `PRO` / `BUSINESS` / `ENTERPRISE`.
- `LicenseValidationError` — string-literal enum with seven variants (six verbatim + additive `REVOKED`).
### Interfaces
`PublicKey`, `SignKey`, `LicensePayload`, `LicenseDetails`,
`LicenseGenerationOptions`, `LicenseGenerationResult`,
`LicenseValidationOptions`, `LicenseValidationFailure`,
`LicenseValidationSuccess`, `LicenseParsedResult`, `KeyRegistry`,
`LicenseManagerConfig`, `QuickLicenseOptions`, `CreatePayloadInjection`,
`RevocationStore` (re-export).
### Types
`LicenseValidationResult = LicenseValidationSuccess | LicenseValidationFailure`.
## Implementation Notes
Every enum is declared with literal numeric or string values that match
the upstream wire format; do not renumber. `LicensePayload` is plain
`number` for each field — runtime bounds checks live in
`packPayload` (via `LICENSE_SCHEMA`) and the Zod schemas in
`src/schemas/schemas.ts`, leaving the type free of brand types so
consumers can construct payloads from arbitrary integer math without
fighting the type system.

View File

@@ -0,0 +1,110 @@
# `@suaveplan/license` — validate
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
License-key validation: parse, verify signature, check version, check
expiration, surface every failure as a typed enum on a discriminated
union. `validateLicense` accepts `publicKeys` as either a `Map` or a
plain `Record` (matching upstream), honours an optional `AbortSignal`,
and emits the OpenTelemetry counter / histogram pair on every call.
`isValidLicense` is a convenience boolean wrapper.
The package's truncated-signature scheme (default 20 bytes) cannot use
standard secp256k1 verification (which requires the full 64-byte
signature). The upstream-compatible fallback hashes `payloadBytes ||
publicKey.p` with SHA-256 and compares the first N bytes against the
embedded signature bytes — giving ~2^160 forgery resistance for the
default 20-byte length.
## Features
- Seven failure modes returned on the discriminated union:
`MALFORMED`, `INVALID_ENCODING`, `INVALID_SIGNATURE`,
`UNKNOWN_SIGNING_KEY`, `EXPIRED`, `UNSUPPORTED_VERSION`, `REVOKED`
(`REVOKED` is surfaced by `LicenseManager.validate`).
- Full-signature path uses `@suaveplan/crypto-curve`'s
`secp256k1.verify` on the SHA-256 digest of `payloadBytes`.
- Truncated-signature path uses the upstream hash-prefix scheme.
- Zod boundary validation on `options`; failures raise
`LicenseError(LICENSE.INVALID_INPUT)`.
- OTel emission: counter `license.validate_total{license.valid,
license.error?}` and histogram `license.validate_duration_ms`.
- Pre-aborted `AbortSignal` rejects with
`LicenseError(LICENSE.ABORTED)` before any crypto runs.
## Usage: Basic
```ts
import { validateLicense } from "@suaveplan/license/validate/validate";
const v = await validateLicense(key, {
publicKeys: { 0: { p: publicKeyBytes } },
});
if (v.success) {
console.log("Tier:", v.details.packageTier);
} else {
console.warn("Invalid:", v.error, v.message);
}
```
## Usage: Advanced
Validate with an injected `referenceDate`, `creationDate` for a
relative-day expiry check, and an `AbortSignal` for cancellation:
```ts
import { validateLicense } from "@suaveplan/license/validate/validate";
import { LicenseError } from "@suaveplan/license";
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 100);
try {
const v = await validateLicense(key, {
publicKeys: new Map([[0, { p }]]),
referenceDate: new Date("2026-06-17T00:00:00Z"),
creationDate: new Date("2026-06-01T00:00:00Z"),
signal: ctrl.signal,
});
if (v.success) {
// ...
}
} catch (e) {
if (e instanceof LicenseError && e.code === "LICENSE.ABORTED") {
console.warn("Validation cancelled");
}
}
```
## API Reference
### `validateLicense(licenseKey, options): Promise<LicenseValidationResult>`
Options: `publicKeys` (required), `referenceDate?`, `creationDate?`,
`signal?`. Returns the discriminated success/failure union. Throws only
on `LicenseError(LICENSE.INVALID_INPUT)` (bad options) or
`LicenseError(LICENSE.ABORTED)` (aborted signal); every other failure
is surfaced on the result.
### `isValidLicense(licenseKey, options): Promise<boolean>`
Thin wrapper that returns `result.success`.
## Implementation Notes
The truncated-signature scheme is what makes the keys compact (~50-60
chars vs ~120). For most consumer products the ~2^160 forgery
resistance is generous; for higher-value licenses callers should opt
into `useFullSignature: true` at generation time. Validation auto-
detects which scheme the key uses by checking the signature byte
length against `FULL_SIGNATURE_BYTES`.
The OTel emission runs on every code path — including aborts and
parse failures — so dashboards always see a complete success/failure
ratio. The histogram's unit is documented as `ms`; the recorded value
is `Date.now() - start`, a wall-clock measurement (acceptable here
because validation latency is dominated by the crypto, not by clock
skew).