first commit
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
# `commands/license/hex`
|
||||
|
||||
## Purpose
|
||||
|
||||
Tiny shared hex encode/decode helpers used by the license sub-commands
|
||||
to serialise and deserialise the secp256k1 key material carried in the
|
||||
`LicenseKeypairFile` JSON envelope. Extracted from `license.ts` so the
|
||||
license command module stays within the 300-line hard limit and so
|
||||
tests can exercise the hex boundary failures in isolation. Every public
|
||||
failure here surfaces as a `ServerKeysError(SERVER_KEYS_INVALID_INPUT)`
|
||||
with a precise error message, so the top-level `runFunction` wrapper
|
||||
prints actionable input diagnostics.
|
||||
|
||||
The choice of hex (rather than base32 or base64) mirrors upstream's JSON
|
||||
key file layout — operators editing key files by hand find hex easier to
|
||||
inspect, and the secp256k1 secret scalar (32 bytes) and public key (33
|
||||
bytes compressed) fit comfortably in a single line each.
|
||||
|
||||
## Features
|
||||
|
||||
- `bytesToHex(bytes)` — lowercase hex, no separator, no `0x` prefix.
|
||||
- `hexToBytes(hex)` — strict parser: rejects odd lengths and any
|
||||
non-hex character with a positional error message.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { bytesToHex, hexToBytes } from "@suaveplan/server-keys/commands/license/hex";
|
||||
|
||||
const bytes = new Uint8Array([0xab, 0xcd, 0xef]);
|
||||
const hex = bytesToHex(bytes); // "abcdef"
|
||||
const round = hexToBytes(hex); // Uint8Array([0xab, 0xcd, 0xef])
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Round-trip every byte with property-based testing:
|
||||
|
||||
```ts
|
||||
import fc from "fast-check";
|
||||
import { bytesToHex, hexToBytes } from "@suaveplan/server-keys/commands/license/hex";
|
||||
|
||||
fc.assert(
|
||||
fc.property(fc.uint8Array({ minLength: 0, maxLength: 1024 }), (input) => {
|
||||
const round = hexToBytes(bytesToHex(input));
|
||||
expect(round).toEqual(input);
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
Decode rejection on malformed input:
|
||||
|
||||
```ts
|
||||
expect(() => hexToBytes("abc")).toThrow(/length must be even/);
|
||||
expect(() => hexToBytes("zz")).toThrow(/Invalid hex byte "zz" at offset 0/);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `bytesToHex(bytes: Uint8Array) => string`
|
||||
- `hexToBytes(hex: string) => Uint8Array` — throws
|
||||
`ServerKeysError(SERVER_KEYS_INVALID_INPUT)` on odd length or non-hex
|
||||
characters.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`bytesToHex` uses `padStart(2, "0")` instead of the `& 0xff` mask trick
|
||||
because the input is already `Uint8Array` (each element is guaranteed
|
||||
to be in `[0, 255]`), and the pad form keeps the per-byte intent
|
||||
explicit at zero runtime cost. `hexToBytes` does not use a regex pre-
|
||||
check because the per-byte `Number.parseInt(..., 16)` already enforces
|
||||
the character set and provides a more useful positional error message
|
||||
on failure.
|
||||
@@ -0,0 +1,133 @@
|
||||
# `commands/license/license`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Wraps `@suaveplan/license` `LicenseManager` into the five sub-commands
|
||||
the operator-facing `license` bin exposes: `keygen`, `generate`,
|
||||
`validate`, `parse`, `revoke`, plus an `exportPublicKeyFile` helper that
|
||||
derives a public-keys JSON from a freshly generated keypair file. Each
|
||||
sub-command translates a `LicenseError` into
|
||||
`ServerKeysError(SERVER_KEYS_LICENSE_FAILED)` with the original error
|
||||
preserved as `cause`, so the top-level `runFunction` wrapper still
|
||||
prints a stable code on failure. Input boundary errors raise
|
||||
`SERVER_KEYS_INVALID_INPUT`, and missing key files raise
|
||||
`SERVER_KEYS_FILE_NOT_FOUND` — all four error paths are exercised in the
|
||||
spec-conformance tests.
|
||||
|
||||
The on-disk JSON envelopes (`LicenseKeypairFile`, `LicensePublicKeyFile`)
|
||||
are deliberately minimal: hex-encoded secp256k1 secret + public, a
|
||||
numeric key id, and an ISO-8601 creation timestamp. Operators can edit
|
||||
them by hand without a tool. The revocation store is a JSON array of
|
||||
hex license fingerprints — created on first revoke, append-only.
|
||||
|
||||
## Features
|
||||
|
||||
- `keygen(outputPath, keyId, options?)` — fresh secp256k1 keypair via
|
||||
`crypto-curve`'s `secp256k1.randomPrivateKey()` + `getPublicKey()`.
|
||||
- `generate(opts)` — `LicenseManager.generate` wrapper with hex-encoded
|
||||
signing-key loading.
|
||||
- `validate(licenseKey, opts)` — public-keys JSON loader, supports
|
||||
either an object or an array layout.
|
||||
- `parse(licenseKey, options?)` — synchronous, no signature check.
|
||||
- `revoke(licenseKey, opts)` — appends to a JSON revocation-store
|
||||
file; creates the file if absent.
|
||||
- `exportPublicKeyFile(keypairFile, outputPath)` — derive the public-
|
||||
keys file from a freshly generated keypair (used by the bin's
|
||||
`--export-pub <path>` flag and by tests).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
keygen,
|
||||
generate,
|
||||
validate,
|
||||
parse,
|
||||
revoke,
|
||||
exportPublicKeyFile,
|
||||
} from "@suaveplan/server-keys/commands/license/license";
|
||||
import { PackageTier } from "@suaveplan/license";
|
||||
|
||||
const kp = await keygen("/tmp/lic/keys.json", 1);
|
||||
await exportPublicKeyFile(kp, "/tmp/lic/public-keys.json");
|
||||
|
||||
const lic = await generate({
|
||||
signingKeyPath: "/tmp/lic/keys.json",
|
||||
productId: 42,
|
||||
tier: PackageTier.PRO,
|
||||
expirationDays: 365,
|
||||
});
|
||||
|
||||
const valid = await validate(lic.key, {
|
||||
publicKeysPath: "/tmp/lic/public-keys.json",
|
||||
});
|
||||
expect(valid.success).toBe(true);
|
||||
|
||||
const parsed = parse(lic.key);
|
||||
expect(parsed.payload.productId).toBe(42);
|
||||
|
||||
await revoke(lic.key, { revocationStorePath: "/tmp/lic/revocations.json" });
|
||||
const after = await validate(lic.key, {
|
||||
publicKeysPath: "/tmp/lic/public-keys.json",
|
||||
});
|
||||
// `revoke` modifies the in-memory store only inside the same Manager;
|
||||
// the file-backed store is observed by the next caller that constructs
|
||||
// a Manager with that store path (see bin/license.ts for the wiring).
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Flag a license as a TRIAL with a feature mask, then validate against a
|
||||
fixed reference date for deterministic expiration windows:
|
||||
|
||||
```ts
|
||||
import { generate, validate } from "@suaveplan/server-keys/commands/license/license";
|
||||
import { LicenseFlags, PackageTier } from "@suaveplan/license";
|
||||
|
||||
const lic = await generate({
|
||||
signingKeyPath: "/tmp/lic/keys.json",
|
||||
productId: 1,
|
||||
tier: PackageTier.BASIC,
|
||||
expirationDays: 30,
|
||||
flags: LicenseFlags.TRIAL,
|
||||
featureMask: 0b1011, // features 0, 1, 3
|
||||
useAbsoluteTime: false,
|
||||
useFullSignature: true,
|
||||
});
|
||||
|
||||
const checked = await validate(lic.key, {
|
||||
publicKeysPath: "/tmp/lic/public-keys.json",
|
||||
referenceDate: new Date("2026-01-01T00:00:00Z"),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `keygen(outputPath, keyId, options?) => Promise<LicenseKeypairFile>`
|
||||
- `generate(opts: GenerateLicenseOptions) =>
|
||||
Promise<LicenseGenerationResult>`
|
||||
- `validate(licenseKey, opts: ValidateLicenseOptions) =>
|
||||
Promise<LicenseValidationResult>`
|
||||
- `parse(licenseKey, options?: LicenseCommandOptions) =>
|
||||
LicenseParsedResult`
|
||||
- `revoke(licenseKey, opts: RevokeLicenseOptions) => Promise<string>`
|
||||
- `exportPublicKeyFile(keypairFile, outputPath) =>
|
||||
Promise<LicensePublicKeyFile>`
|
||||
|
||||
All option bags accept `{ logger?, signal? }` for injection.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`generate` constructs a `LicenseManager` per call (instead of caching
|
||||
one across sub-commands) because each call's signing key may be
|
||||
different — the bin invocation is the granularity that owns a key. The
|
||||
revocation store loader tolerates `ENOENT` (no file yet) but treats any
|
||||
other I/O error as a `LICENSE_FAILED` failure so operators don't
|
||||
silently revoke into a misconfigured path. The optional `signal`
|
||||
parameter is checked at the boundary of each sub-command and at the
|
||||
start of generate/validate so an aborted run never burns CPU on the
|
||||
LicenseManager's hash + signature work.
|
||||
@@ -0,0 +1,98 @@
|
||||
# `commands/license/license.types`
|
||||
|
||||
## Purpose
|
||||
|
||||
Type definitions for every license sub-command's options bag and for
|
||||
the JSON file envelopes the bin reads and writes. Centralising these
|
||||
here keeps the sub-command functions in `license.ts` focused on
|
||||
behaviour and makes the boundary contract explicit — both for
|
||||
operators editing the JSON files by hand and for downstream programmatic
|
||||
consumers that want to write a typed wrapper around the bin's surface.
|
||||
|
||||
The file envelopes mirror the upstream layout exactly so operator
|
||||
scripts that read or write these files continue to work without
|
||||
modification.
|
||||
|
||||
## Features
|
||||
|
||||
- `LicenseLogger` — `Pick<LoggerLike, "info" | "error">`; injected.
|
||||
- `LicenseCommandOptions` — shared `{ logger?, signal? }` base.
|
||||
- `LicenseKeypairFile` — `{ keyId, signingKey (hex), publicKey (hex),
|
||||
created (ISO 8601) }`. Persisted by `keygen`.
|
||||
- `LicensePublicKeyFile` — `{ keyId, publicKey (hex) }`. Persisted by
|
||||
`exportPublicKeyFile`; read by `validate`.
|
||||
- `GenerateLicenseOptions`, `ValidateLicenseOptions`,
|
||||
`RevokeLicenseOptions` — typed per-sub-command bags.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type {
|
||||
LicenseKeypairFile,
|
||||
GenerateLicenseOptions,
|
||||
} from "@suaveplan/server-keys/commands/license/license.types";
|
||||
import { PackageTier } from "@suaveplan/license";
|
||||
|
||||
const opts: GenerateLicenseOptions = {
|
||||
signingKeyPath: "/tmp/keys.json",
|
||||
productId: 42,
|
||||
tier: PackageTier.PRO,
|
||||
expirationDays: 365,
|
||||
};
|
||||
|
||||
const file: LicenseKeypairFile = JSON.parse(
|
||||
await fs.readFile("/tmp/keys.json", "utf-8"),
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Construct a typed orchestration script that emits a generation request,
|
||||
validates against a separately stored public-key file, and writes the
|
||||
revocation back to a third path:
|
||||
|
||||
```ts
|
||||
import type {
|
||||
GenerateLicenseOptions,
|
||||
ValidateLicenseOptions,
|
||||
RevokeLicenseOptions,
|
||||
} from "@suaveplan/server-keys/commands/license/license.types";
|
||||
|
||||
interface LicenseFlow {
|
||||
generate: GenerateLicenseOptions;
|
||||
validate: ValidateLicenseOptions;
|
||||
revoke: RevokeLicenseOptions;
|
||||
}
|
||||
|
||||
const flow: LicenseFlow = {
|
||||
generate: {
|
||||
signingKeyPath: "/etc/lic/keys.json",
|
||||
productId: 1,
|
||||
tier: 2,
|
||||
expirationDays: 730,
|
||||
},
|
||||
validate: { publicKeysPath: "/etc/lic/public-keys.json" },
|
||||
revoke: { revocationStorePath: "/var/lib/lic/revocations.json" },
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface LicenseLogger`
|
||||
- `interface LicenseCommandOptions`
|
||||
- `interface LicenseKeypairFile`
|
||||
- `interface LicensePublicKeyFile`
|
||||
- `interface GenerateLicenseOptions extends LicenseCommandOptions`
|
||||
- `interface ValidateLicenseOptions extends LicenseCommandOptions`
|
||||
- `interface RevokeLicenseOptions extends LicenseCommandOptions`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All fields on `LicenseKeypairFile` and `LicensePublicKeyFile` are
|
||||
`readonly` so accidental mutation after a JSON parse is a TS error.
|
||||
`LicenseValidationOptions.publicKeysPath` is named verbosely (instead
|
||||
of `publicKeyPath`) because the file MAY hold many keyed entries —
|
||||
either as a single object or as an array — and the plural is more
|
||||
honest about the multi-key layout. `ValidateLicenseOptions.referenceDate`
|
||||
is `Date | undefined`; the bin's `--reference-date <iso>` parses to a
|
||||
`Date` and forwards.
|
||||
Reference in New Issue
Block a user