first commit
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
# `commands/files/files`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Local-packer file-transport commands: `prepareFileCommand`,
|
||||
`validateFileCommand`, `getFileStatsCommand`, `validatePacketCommand`.
|
||||
These four functions implement the operator-facing surface every file-
|
||||
transport bin in `@suaveplan/server-keys` calls into. Reconstruction
|
||||
lives in the sibling `serve-file.ts`. The implementation is fully local —
|
||||
no HTTP, no remote file server — backed by `@suaveplan/packer`'s
|
||||
whole-file `Packer` / `Unpacker` and plain Node `fs`.
|
||||
|
||||
The choice of a local packer (rather than an HTTP client to a remote
|
||||
file-server, which the upstream package shipped) was made deliberately:
|
||||
the upstream HTTP design forces operators to stand up a separate
|
||||
file-server before they can prepare or serve a file, and the binary
|
||||
contract is dominated by the packet wire format anyway. Owning the
|
||||
format here keeps the suaveplan test surface auditable without a network
|
||||
hop and removes a non-trivial deployment dependency.
|
||||
|
||||
## Features
|
||||
|
||||
- Fixed-size packetisation (`packetSize`, default 1 MiB) with a JSON
|
||||
envelope per packet on disk.
|
||||
- ed25519 signature per packet using the vault's `sign` slot secret;
|
||||
recipient verifies against the manifest's `signerPublicKey`.
|
||||
- Manifest is a single JSON document that lists every packet's
|
||||
`index`, `checksum`, `filename`, and `originalSize`.
|
||||
- Cooperative abort at every packet boundary via `options.signal`.
|
||||
- Typed `ServerKeysError` for every failure (`INVALID_INPUT`,
|
||||
`VAULT_DECRYPT_FAILED`, `PACKET_INTEGRITY_FAILED`, `FILE_NOT_FOUND`,
|
||||
`ABORTED`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
prepareFileCommand,
|
||||
validateFileCommand,
|
||||
getFileStatsCommand,
|
||||
} from "@suaveplan/server-keys/commands/files/files";
|
||||
|
||||
const stats = await getFileStatsCommand("/tmp/data.bin", {
|
||||
packetSize: 256 * 1024,
|
||||
});
|
||||
console.log(stats.estimatedPackets);
|
||||
|
||||
await prepareFileCommand(
|
||||
"/tmp/data.bin",
|
||||
"/tmp/keys/server.vault",
|
||||
"serverpw1234",
|
||||
{ output: "/tmp/prepared", packetSize: 256 * 1024 },
|
||||
);
|
||||
|
||||
const result = await validateFileCommand("/tmp/prepared");
|
||||
expect(result.isComplete).toBe(true);
|
||||
expect(result.invalidPackets).toEqual([]);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Single-packet validation against an explicit hex public key (useful for
|
||||
spot-checking a remote-supplied packet without trusting the colocated
|
||||
manifest):
|
||||
|
||||
```ts
|
||||
import { validatePacketCommand } from "@suaveplan/server-keys/commands/files/files";
|
||||
|
||||
const r = await validatePacketCommand(
|
||||
"/tmp/prepared/packet.000000.bin",
|
||||
"abcd...ed25519-public-key-hex",
|
||||
);
|
||||
expect(r.isValid).toBe(true);
|
||||
```
|
||||
|
||||
Detect a tampered packet:
|
||||
|
||||
```ts
|
||||
import { promises as fs } from "node:fs";
|
||||
const target = "/tmp/prepared/packet.000003.bin";
|
||||
const original = await fs.readFile(target, "utf-8");
|
||||
const obj = JSON.parse(original);
|
||||
const bytes = Buffer.from(obj.data, "hex");
|
||||
bytes[10] ^= 0xff;
|
||||
obj.data = bytes.toString("hex");
|
||||
await fs.writeFile(target, JSON.stringify(obj));
|
||||
const r = await validateFileCommand("/tmp/prepared");
|
||||
expect(r.isComplete).toBe(false);
|
||||
expect(r.invalidPackets).toContain(3);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `prepareFileCommand(filePath, vaultPath, password, options?) =>
|
||||
Promise<FileManifest>`
|
||||
- `validateFileCommand(packetDir, options?) =>
|
||||
Promise<ValidateFileResult>`
|
||||
- `getFileStatsCommand(filePath, options?) => Promise<FileStatsResult>`
|
||||
- `validatePacketCommand(packetPath, verifyKeyHex, options?) =>
|
||||
Promise<ValidatePacketResult>`
|
||||
|
||||
All take `FileCommandOptions = { output?, packetSize?, logger?,
|
||||
signal? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`prepareFileCommand` constructs a new `Packer` per packet so the
|
||||
underlying event bus stays scoped to a single pack call — the abstract
|
||||
`Packer` is an `EventEmitter` and we don't want pack events leaking
|
||||
across packets. Each packet stores its `signature`, `data`, `checksum`,
|
||||
and `originalSize` in a small JSON envelope (`{ data, signature,
|
||||
checksum, originalSize }`), all hex-encoded — operators inspecting
|
||||
packets by hand can paste them into a hex viewer without further
|
||||
tooling. The intentional `compressionLevel: 0` (no gzip) keeps each
|
||||
packet's plaintext slice byte-identical to the input region; without
|
||||
this, signing operates over the gzip stream and reconstruction
|
||||
requires inverting it, which is an avoidable layer for the local
|
||||
transport.
|
||||
@@ -0,0 +1,93 @@
|
||||
# `commands/files/files.types`
|
||||
|
||||
## Purpose
|
||||
|
||||
Type definitions and the default packet-size constant shared by every
|
||||
file-transport command in `@suaveplan/server-keys`. Centralising the
|
||||
shapes here lets `files.ts`, `serve-file.ts`, and `packet-io.ts` all
|
||||
agree on the wire format without circular imports, and gives downstream
|
||||
programmatic consumers a single import for the typed return envelopes
|
||||
(`FileManifest`, `ValidateFileResult`, `FileStatsResult`,
|
||||
`ValidatePacketResult`, `PacketRecord`).
|
||||
|
||||
The `DEFAULT_PACKET_SIZE = 1_048_576` (1 MiB) constant mirrors the
|
||||
upstream default; both the prepare and stats commands fall back to it
|
||||
when the caller doesn't override `packetSize`.
|
||||
|
||||
## Features
|
||||
|
||||
- `FilesLogger` — `Pick<LoggerLike, "info" | "error">`; injected.
|
||||
- `FileCommandOptions` — shared `{ output?, packetSize?, logger?,
|
||||
signal? }`.
|
||||
- `FileManifest` — top-level on-disk JSON shape, schema-versioned via
|
||||
`schemaVersion: 1`.
|
||||
- `PacketRecord` — per-packet metadata in the manifest.
|
||||
- `ValidateFileResult`, `FileStatsResult`, `ValidatePacketResult` —
|
||||
typed return envelopes.
|
||||
- `DEFAULT_PACKET_SIZE = 1_048_576`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type {
|
||||
FileCommandOptions,
|
||||
FileManifest,
|
||||
} from "@suaveplan/server-keys/commands/files/files.types";
|
||||
|
||||
const options: FileCommandOptions = {
|
||||
output: "/tmp/prepared",
|
||||
packetSize: 512 * 1024,
|
||||
};
|
||||
|
||||
function packetCount(m: FileManifest): number {
|
||||
return m.totalPackets;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Validate a third-party-produced manifest at the boundary before
|
||||
consuming it:
|
||||
|
||||
```ts
|
||||
import type { FileManifest } from "@suaveplan/server-keys/commands/files/files.types";
|
||||
|
||||
function isFileManifest(value: unknown): value is FileManifest {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
return (
|
||||
v.schemaVersion === 1 &&
|
||||
typeof v.fileId === "string" &&
|
||||
typeof v.originalFilename === "string" &&
|
||||
typeof v.originalSize === "number" &&
|
||||
typeof v.packetSize === "number" &&
|
||||
typeof v.totalPackets === "number" &&
|
||||
typeof v.signerPublicKey === "string" &&
|
||||
Array.isArray(v.packets)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface FilesLogger`
|
||||
- `interface FileCommandOptions`
|
||||
- `interface PacketRecord { index, checksum, filename, originalSize }`
|
||||
- `interface FileManifest { schemaVersion, fileId, originalFilename,
|
||||
originalSize, packetSize, totalPackets, signerPublicKey, packets }`
|
||||
- `interface ValidateFileResult { isComplete, totalPackets,
|
||||
validPackets, invalidPackets, fileId }`
|
||||
- `interface FileStatsResult { inputPath, originalSize, packetSize,
|
||||
estimatedPackets, estimatedOverheadBytes, estimatedPackedBytes,
|
||||
lastModified }`
|
||||
- `interface ValidatePacketResult { isValid, index, checksum, error? }`
|
||||
- `const DEFAULT_PACKET_SIZE: 1_048_576`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Every field is `readonly` to keep accidental in-place mutation out of
|
||||
the call sites; the manifest is also frozen-on-write by the JSON
|
||||
round-trip in `writeManifest`. The `schemaVersion: 1` literal type pins
|
||||
the on-disk format — a future v2 change would add `2` as a discriminator
|
||||
and require an explicit migration path in the orchestration layer
|
||||
instead of silently changing the shape.
|
||||
@@ -0,0 +1,87 @@
|
||||
# `commands/files/packet-io`
|
||||
|
||||
## Purpose
|
||||
|
||||
Shared on-disk format helpers for the file-transport commands. Holds
|
||||
the canonical packet envelope `{ data, signature, checksum,
|
||||
originalSize }` (all hex-encoded scalars in JSON), the canonical
|
||||
packet filename convention (`packet.<6-digit-index>.bin`), the manifest
|
||||
filename (`manifest.json`), and the read/write helpers used by both
|
||||
`prepareFileCommand` and `serveFileCommand`.
|
||||
|
||||
Lives in its own module so the format is the contract: any tool that
|
||||
agrees on the envelope shape can interop with a server-keys-prepared
|
||||
directory without depending on the full file-command surface. Also keeps
|
||||
`files.ts` and `serve-file.ts` under the 300-line hard limit by lifting
|
||||
out the duplicated serialisation code.
|
||||
|
||||
## Features
|
||||
|
||||
- `packetFilename(index)` — zero-padded 6-digit filename for
|
||||
deterministic sort order.
|
||||
- `bytesToHex` / `hexToBytes` — lowercase hex, strict input validation.
|
||||
- `serializePacket(packed, originalSize)` — JSON-encode a `PackedData`
|
||||
envelope.
|
||||
- `deserializePacket(buffer)` — parse a JSON envelope back into the
|
||||
shape `Unpacker.unpack` expects, with a typed `PACKET_INTEGRITY_FAILED`
|
||||
failure on malformed JSON.
|
||||
- `readManifest(dir)` / `writeManifest(dir, manifest)` — single-file
|
||||
JSON manifest I/O with directory creation.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
packetFilename,
|
||||
serializePacket,
|
||||
writeManifest,
|
||||
} from "@suaveplan/server-keys/commands/files/packet-io";
|
||||
|
||||
const filename = packetFilename(7); // "packet.000007.bin"
|
||||
const text = serializePacket(packed, 1024);
|
||||
await fs.writeFile(`/tmp/prepared/${filename}`, text);
|
||||
await writeManifest("/tmp/prepared", manifest);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Round-trip a packet envelope without touching disk:
|
||||
|
||||
```ts
|
||||
import {
|
||||
deserializePacket,
|
||||
serializePacket,
|
||||
} from "@suaveplan/server-keys/commands/files/packet-io";
|
||||
|
||||
const text = serializePacket(packed, slice.byteLength);
|
||||
const buf = new TextEncoder().encode(text);
|
||||
const { packed: rehydrated, envelope } = deserializePacket(buf);
|
||||
expect(envelope.checksum).toBe(packed.metadata.checksum);
|
||||
expect(Array.from(rehydrated.data)).toEqual(Array.from(packed.data));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `MANIFEST_FILENAME = "manifest.json"`
|
||||
- `packetFilename(index: number) => string`
|
||||
- `bytesToHex(bytes: Uint8Array) => string`
|
||||
- `hexToBytes(hex: string) => Uint8Array` (throws on malformed input)
|
||||
- `interface SerializedPacket { data, signature, checksum, originalSize }`
|
||||
- `serializePacket(packed, originalSize) => string`
|
||||
- `deserializePacket(buffer) => { packed, envelope }`
|
||||
- `writeManifest(dir, manifest) => Promise<void>`
|
||||
- `readManifest(dir) => Promise<FileManifest>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`readManifest` translates a missing file into
|
||||
`SERVER_KEYS_FILE_NOT_FOUND`; this is the closest match in the
|
||||
registry for "the directory exists but the manifest entry is absent"
|
||||
and aligns with how the keys commands report missing key files.
|
||||
`deserializePacket` raises `PACKET_INTEGRITY_FAILED` on malformed JSON
|
||||
rather than `INVALID_INPUT` because the input is a file the caller
|
||||
already trusted enough to read — the failure is on the storage side,
|
||||
not on a CLI option-bag boundary. The packet envelope deliberately
|
||||
hex-encodes both `data` and `signature` (rather than base64) to match
|
||||
the rest of the package's binary-on-disk convention and to keep the
|
||||
file readable by hand.
|
||||
@@ -0,0 +1,87 @@
|
||||
# `commands/files/serve-file`
|
||||
|
||||
## Purpose
|
||||
|
||||
Reconstructs the original file from a directory of prepared packets by
|
||||
verifying each packet's ed25519 signature + sha256 checksum against the
|
||||
manifest and concatenating the verified plaintext slices in index order.
|
||||
The reconstruction is byte-identical to the input file passed to
|
||||
`prepareFileCommand`. Failure to verify any packet aborts the operation
|
||||
with `ServerKeysError(SERVER_KEYS_PACKET_INTEGRITY_FAILED)` BEFORE any
|
||||
bytes are written to disk, so the output path is never half-written.
|
||||
|
||||
Lives in a sibling file to `files.ts` because the reconstruction path
|
||||
has a distinctly different shape (in-memory contiguous buffer, strict
|
||||
ordering, single output write) compared to the parallel packet-level
|
||||
work the rest of the file commands do.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict index ordering: refuses to serve if any packet's `index` does
|
||||
not match its position in the sorted manifest.
|
||||
- Signature + checksum verification per packet via `Unpacker.unpack`
|
||||
with `verifySignature: true, verifyKey, verifyChecksum: true`.
|
||||
- Single allocation for the reconstructed buffer (`new
|
||||
Uint8Array(manifest.originalSize)`), populated in place — bounds the
|
||||
serve-path memory at exactly the original file size.
|
||||
- Cooperative abort via `options.signal`.
|
||||
- Output defaults to `<packetDir>/<originalFilename>`; override with
|
||||
`options.output`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { serveFileCommand } from "@suaveplan/server-keys/commands/files/serve-file";
|
||||
|
||||
const result = await serveFileCommand("/tmp/prepared", {
|
||||
output: "/tmp/reconstructed.bin",
|
||||
});
|
||||
expect(result.bytesWritten).toBe(originalSize);
|
||||
expect(result.packetsRead).toBe(totalPackets);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Drive the reconstruction with a capturing logger and an external abort
|
||||
signal, then compare the result byte-for-byte to the original:
|
||||
|
||||
```ts
|
||||
import { promises as fs } from "node:fs";
|
||||
import { serveFileCommand } from "@suaveplan/server-keys/commands/files/serve-file";
|
||||
|
||||
const lines: string[] = [];
|
||||
const ac = new AbortController();
|
||||
setTimeout(() => ac.abort(), 5000);
|
||||
|
||||
const result = await serveFileCommand("/tmp/prepared", {
|
||||
output: "/tmp/out.bin",
|
||||
logger: { info: (m) => lines.push(m), error: (m) => lines.push(m) },
|
||||
signal: ac.signal,
|
||||
});
|
||||
|
||||
const original = await fs.readFile("/tmp/data.bin");
|
||||
const reconstructed = await fs.readFile(result.outputPath);
|
||||
expect(Buffer.compare(original, reconstructed)).toBe(0);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `serveFileCommand(packetDir, options?) => Promise<ServeFileResult>`
|
||||
- `interface ServeFileResult { outputPath, bytesWritten, packetsRead,
|
||||
fileId }`
|
||||
|
||||
`options: FileCommandOptions = { output?, logger?, signal? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The reconstruction buffer is sized to `manifest.originalSize` and we
|
||||
assert `writeOffset === manifest.originalSize` at the end so a
|
||||
manifest-vs-actual mismatch is caught as `PACKET_INTEGRITY_FAILED`
|
||||
instead of silently truncating. We sort the manifest's packet records
|
||||
before iterating because the JSON entries may have been written in any
|
||||
order; sorting once and iterating in index order is O(n log n) for n
|
||||
packets, which is dominated by the per-packet AEAD work anyway.
|
||||
Verification is requested with all three checks enabled (`signature`,
|
||||
`checksum`, and length) because the contract is "byte-identical
|
||||
reconstruction" — any weakening would let a tampered packet through
|
||||
with the same number of bytes.
|
||||
@@ -0,0 +1,115 @@
|
||||
# `commands/keys/keys-chain`
|
||||
|
||||
## Purpose
|
||||
|
||||
Hierarchical `ChainedKeySigner`-backed key-lifecycle commands. Each
|
||||
chain command wraps the matching crypto-vault orchestration in a
|
||||
two-step pipeline: generate-or-derive a fresh keychain via
|
||||
`@suaveplan/crypto-keys` (which produces a signed chain bytes blob), then
|
||||
encrypt the keychain with `packVault` from `@suaveplan/crypto-vault`.
|
||||
Persistence writes `<name>.vault` plus a `<name>.chain` file instead of
|
||||
the non-chain `<name>.pub` so operators can tell at a glance which key
|
||||
family a directory holds.
|
||||
|
||||
`verify-key-chain` rounds out the surface: it loads a chain file +
|
||||
trusted root file, extracts the root's `sign` public key (whether the
|
||||
root file is a signed-public-key file or another chain file), and
|
||||
returns a structured `{ valid, depth, errors }` result without
|
||||
throwing on a signature failure.
|
||||
|
||||
## Features
|
||||
|
||||
- Chain command parity with the non-chain commands: `create-root`,
|
||||
`create-server`, `roll-server`, all producing matching vault + chain
|
||||
artefacts.
|
||||
- `verifyKeyChainCommand` accepts both signed-public-key files and
|
||||
chain files as the trusted-root input — automatic fallback.
|
||||
- Same `KeyCommandOptions` shape as `keys.ts`: output dir, logger,
|
||||
abort signal.
|
||||
- `parseChain` helper for non-verifying introspection (depth count).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
createRootKeysChainCommand,
|
||||
createServerKeysChainCommand,
|
||||
verifyKeyChainCommand,
|
||||
} from "@suaveplan/server-keys/commands/keys/keys-chain";
|
||||
|
||||
await createRootKeysChainCommand("rootpw1234", { output: "/tmp/keys" });
|
||||
await createServerKeysChainCommand(
|
||||
"/tmp/keys/root.vault",
|
||||
"rootpw1234",
|
||||
"serverpw1234",
|
||||
{ output: "/tmp/keys" },
|
||||
);
|
||||
const result = await verifyKeyChainCommand(
|
||||
"/tmp/keys/server.chain",
|
||||
"/tmp/keys/root.chain",
|
||||
);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.depth).toBe(2);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Detect a tampered chain by flipping a byte in the chain file and
|
||||
re-running the verifier; the result reports `valid: false` with a
|
||||
populated `errors` array instead of throwing:
|
||||
|
||||
```ts
|
||||
import { promises as fs } from "node:fs";
|
||||
import {
|
||||
rollServerKeysChainCommand,
|
||||
verifyKeyChainCommand,
|
||||
} from "@suaveplan/server-keys/commands/keys/keys-chain";
|
||||
|
||||
await rollServerKeysChainCommand(
|
||||
"/tmp/keys/root.vault",
|
||||
"rootpw1234",
|
||||
"serverpw1234",
|
||||
1,
|
||||
{ output: "/tmp/keys" },
|
||||
);
|
||||
|
||||
const chainPath = "/tmp/keys/server.v2.chain";
|
||||
const original = await fs.readFile(chainPath);
|
||||
const tampered = new Uint8Array(original);
|
||||
tampered[tampered.length - 1] ^= 0xff;
|
||||
await fs.writeFile(chainPath, tampered);
|
||||
|
||||
const r = await verifyKeyChainCommand(chainPath, "/tmp/keys/root.chain");
|
||||
expect(r.valid).toBe(false);
|
||||
expect(r.errors.length).toBeGreaterThan(0);
|
||||
await fs.writeFile(chainPath, original); // restore
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createRootKeysChainCommand(password, options?) => Promise<void>`
|
||||
- `createServerKeysChainCommand(rootVaultPath, rootPassword,
|
||||
serverPassword, options?) => Promise<void>`
|
||||
- `rollServerKeysChainCommand(rootVaultPath, rootPassword,
|
||||
serverPassword, currentVersion, options?) => Promise<void>`
|
||||
- `verifyKeyChainCommand(chainPath, rootKeyPath, options?) =>
|
||||
Promise<{ valid, depth, errors }>`
|
||||
- `parseChain(chainBytes) => Promise<{ depth }>`
|
||||
- `viewPublicKeys` re-export — for callers that need to decode a
|
||||
signed-public-key file in the same import.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The cast through
|
||||
`as unknown as Parameters<typeof createServerKeysWithChain>[0]["rootKeys"]`
|
||||
is the safe-narrowing shape `crypto-keys` expects — it requires a
|
||||
`Keychain` typed against its own `LayoutV1Fields` import, which is
|
||||
nominally distinct from crypto-vault's despite being structurally
|
||||
identical. The double declaration of `LayoutV1Fields` across packages
|
||||
mirrors the deliberate decoupling there; the cast keeps the boundary
|
||||
explicit.
|
||||
|
||||
`verifyKeyChainCommand` does not re-throw on `valid: false` because the
|
||||
spec scenario "tampered chain fails verification" requires a structured
|
||||
result, not an exception — exceptions are reserved for input failures
|
||||
(`INVALID_INPUT`, `FILE_NOT_FOUND`, missing `sign` slot).
|
||||
@@ -0,0 +1,116 @@
|
||||
# `commands/keys/keys-io`
|
||||
|
||||
## Purpose
|
||||
|
||||
Private I/O and orchestration-layer helpers shared by every public keys
|
||||
command. Extracted from `keys.ts` to keep each file under the 300-line
|
||||
hard limit while still concentrating filesystem access, vault decryption,
|
||||
signal checking, and logger defaulting in a single auditable module.
|
||||
Every public failure surfaces as a `ServerKeysError` with the matching
|
||||
`SERVER_KEYS_*` code: `FILE_NOT_FOUND`, `VAULT_DECRYPT_FAILED`,
|
||||
`CHAIN_VERIFY_FAILED`, or `ABORTED`.
|
||||
|
||||
All filesystem I/O goes through `node:fs/promises` directly, NOT
|
||||
through `@suaveplan/storage-file`. The storage-file driver appends
|
||||
`.data` / `.meta` extensions to every key on write, which would force
|
||||
operator scripts and downstream consumers to address artefacts by
|
||||
`root.vault.data` instead of the conventional `root.vault`. The CLI
|
||||
contract is byte-name-stable across upstream and suaveplan, so the
|
||||
helpers stay on plain Node fs.
|
||||
|
||||
## Features
|
||||
|
||||
- `pickLogger(options)` — silent default; honours an injected `KeysLogger`.
|
||||
- `checkSignal(signal, command)` — cooperative abort check that throws
|
||||
`ServerKeysError(SERVER_KEYS_ABORTED)`.
|
||||
- `writeVaultPair(dir, vaultName, publicName, vaultBytes, publicBytes)`
|
||||
— single transaction that persists both vault and public-key file via
|
||||
storage-file.
|
||||
- `loadFile(path)` — bytes loader with FILE_NOT_FOUND translation.
|
||||
- `decryptVault(bytes, password, path)` — VAULT_DECRYPT_FAILED on any
|
||||
failure, preserving the `cause` chain.
|
||||
- `loadRootKeysResult(rootVaultPath, password)` — reconstructs the
|
||||
`RootKeysResult` shape the crypto-vault orchestration expects, by
|
||||
re-reading the sibling `.pub` file.
|
||||
- `extractSignPublicKey(bytes, sourcePath)` — pulls the `sign` slot's
|
||||
public key out of a public-keychain blob; CHAIN_VERIFY_FAILED on
|
||||
missing slot.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
loadFile,
|
||||
decryptVault,
|
||||
writeVaultPair,
|
||||
pickLogger,
|
||||
} from "@suaveplan/server-keys/commands/keys/keys-io";
|
||||
|
||||
const bytes = await loadFile("./keys/root.vault");
|
||||
const keychain = await decryptVault(bytes, password, "./keys/root.vault");
|
||||
await writeVaultPair(
|
||||
"./keys",
|
||||
"server.vault",
|
||||
"server.pub",
|
||||
serverVaultBytes,
|
||||
serverPubBytes,
|
||||
);
|
||||
pickLogger(undefined).info("done");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Compose with `checkSignal` for an abort-aware command body, and use
|
||||
`loadRootKeysResult` to feed the crypto-vault `createServerKeys`
|
||||
orchestration:
|
||||
|
||||
```ts
|
||||
import { createServerKeys } from "@suaveplan/crypto-vault";
|
||||
import {
|
||||
checkSignal,
|
||||
loadRootKeysResult,
|
||||
writeVaultPair,
|
||||
} from "@suaveplan/server-keys/commands/keys/keys-io";
|
||||
|
||||
export async function deriveServer(
|
||||
rootVaultPath: string,
|
||||
rootPassword: string,
|
||||
serverPassword: string,
|
||||
signal: AbortSignal | undefined,
|
||||
) {
|
||||
checkSignal(signal, "derive-server");
|
||||
const rootResult = await loadRootKeysResult(rootVaultPath, rootPassword);
|
||||
checkSignal(signal, "derive-server");
|
||||
const server = await createServerKeys(rootResult, serverPassword);
|
||||
return writeVaultPair(
|
||||
"./keys",
|
||||
"server.vault",
|
||||
"server.pub",
|
||||
server.vaultBytes,
|
||||
server.publicKeyBytes,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `pickLogger(options) => KeysLogger`
|
||||
- `checkSignal(signal, command) => void` (throws on abort)
|
||||
- `writeVaultPair(dir, vaultName, publicName, vaultBytes, publicBytes) =>
|
||||
Promise<{ vaultPath, publicPath }>`
|
||||
- `loadFile(path) => Promise<Uint8Array>`
|
||||
- `decryptVault(bytes, password, path) => Promise<Keychain>`
|
||||
- `loadRootKeysResult(path, password) => Promise<RootKeysResult>`
|
||||
- `extractSignPublicKey(bytes, sourcePath) => Promise<Uint8Array>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`storageFor` constructs a fresh `NodeFsStorage` per call instead of
|
||||
caching: each call's `rootDir` is independent (the keys CLI talks to
|
||||
arbitrary user paths), so a cache would conflate unrelated paths and
|
||||
introduce subtle leakage between commands. `loadRootKeysResult` derives
|
||||
the sibling `.pub` path with a `.vault → .pub` substitution because the
|
||||
upstream layout convention is mirrored verbatim — any deviation here
|
||||
breaks `create-server-keys` against an existing `create-root-keys`
|
||||
output. The error translations preserve `cause` so the SuaveplanError
|
||||
chain stays walkable in operator log forensics.
|
||||
@@ -0,0 +1,124 @@
|
||||
# `commands/keys/keys`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Non-chain key-lifecycle commands for `@suaveplan/server-keys`. These
|
||||
functions are the library surface every key/view bin in the package
|
||||
calls into: root keychain creation, server keychain derivation, server
|
||||
keychain rotation, and the four read-only inspectors (`view-keys`,
|
||||
`view-vault`, `view-public-key-file`, `view-chain-public`). The
|
||||
sibling `keys-chain.ts` covers the hierarchical chain variants and
|
||||
`verify-key-chain`; `keys-io.ts` holds the shared filesystem and
|
||||
decryption helpers.
|
||||
|
||||
Every command writes through `@suaveplan/storage-file`'s
|
||||
`NodeFsStorage`, so vault and public-key persistence flows through the
|
||||
same gated I/O as the rest of the storage stack — no direct
|
||||
`fs.writeFile` calls live in this module. The crypto-vault
|
||||
orchestration (`createRootKeys`, `createServerKeys`, `rollServerKeys`,
|
||||
`openVault`, `viewPublicKeys`) is the format owner.
|
||||
|
||||
## Features
|
||||
|
||||
- Filename convention mirroring upstream: `root.vault`/`root.pub`,
|
||||
`server.vault`/`server.pub`, `server.v<N>.vault`/`server.v<N>.pub`.
|
||||
- Structured `ServerKeysError(SERVER_KEYS_*)` failures everywhere —
|
||||
callers always see a typed code on `error.code`.
|
||||
- Injectable `KeysLogger` (defaults to silent) — bins wire a console
|
||||
shim; tests can capture every printed line as a string array.
|
||||
- Cooperative abort via `options.signal`; each command checks before
|
||||
expensive crypto or filesystem work.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
createRootKeysCommand,
|
||||
createServerKeysCommand,
|
||||
} from "@suaveplan/server-keys/commands/keys/keys";
|
||||
|
||||
await createRootKeysCommand("hunter2hunter2", { output: "/tmp/keys" });
|
||||
await createServerKeysCommand(
|
||||
"/tmp/keys/root.vault",
|
||||
"hunter2hunter2",
|
||||
"newserverpw",
|
||||
{ output: "/tmp/keys" },
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Drive the viewers from a higher-level orchestrator with a capturing
|
||||
logger:
|
||||
|
||||
```ts
|
||||
import {
|
||||
viewKeysCommand,
|
||||
viewVaultCommand,
|
||||
viewPublicKeyFileCommand,
|
||||
} from "@suaveplan/server-keys/commands/keys/keys";
|
||||
|
||||
const lines: string[] = [];
|
||||
const logger = {
|
||||
info: (m: string) => lines.push(`I:${m}`),
|
||||
error: (m: string) => lines.push(`E:${m}`),
|
||||
};
|
||||
|
||||
const stat = await viewVaultCommand("/tmp/keys/root.vault", { logger });
|
||||
const keychain = await viewKeysCommand(
|
||||
"/tmp/keys/root.vault",
|
||||
"hunter2hunter2",
|
||||
{ logger },
|
||||
);
|
||||
const pubChain = await viewPublicKeyFileCommand("/tmp/keys/root.pub", {
|
||||
logger,
|
||||
});
|
||||
expect(stat.bytes).toBeGreaterThan(0);
|
||||
expect(Object.keys(pubChain).length).toBeGreaterThan(0);
|
||||
|
||||
// Rotate with an abort guard:
|
||||
const ac = new AbortController();
|
||||
setTimeout(() => ac.abort(), 100);
|
||||
await rollServerKeysCommand(
|
||||
"/tmp/keys/root.vault",
|
||||
"hunter2hunter2",
|
||||
"newserverpw",
|
||||
1,
|
||||
{ output: "/tmp/keys", signal: ac.signal },
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createRootKeysCommand(password, options?) => Promise<void>`
|
||||
- `createServerKeysCommand(rootVaultPath, rootPassword, serverPassword,
|
||||
options?) => Promise<void>`
|
||||
- `rollServerKeysCommand(rootVaultPath, rootPassword, serverPassword,
|
||||
currentVersion, options?) => Promise<void>`
|
||||
- `viewKeysCommand(vaultPath, password, options?) => Promise<Keychain>`
|
||||
- `viewVaultCommand(vaultPath, options?) =>
|
||||
Promise<{ path, bytes }>`
|
||||
- `viewPublicKeyFileCommand(publicKeyPath, options?) =>
|
||||
Promise<PublicKeychain>`
|
||||
- `viewChainPublicCommand(publicKeyPath, signKeyPath?, options?) =>
|
||||
Promise<PublicKeychain>`
|
||||
|
||||
All options are `KeyCommandOptions = { output?, logger?, signal? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`viewKeysCommand` returns the decoded keychain instead of `void` so test
|
||||
harnesses can assert on the result without re-decrypting; the
|
||||
information is already in memory by the time the function logs, and
|
||||
exposing it avoids a redundant decrypt in callers. Slot iteration goes
|
||||
through `Object.entries(keychain as Record<string, unknown>)` because
|
||||
`Keychain` is `Readonly<Record<PurposeTag, KeySlotPrivate>>` where
|
||||
`PurposeTag` is a branded `string & { __brand }` — TS cannot iterate
|
||||
branded keys directly, so a typed-string view is the correct narrowing.
|
||||
The roll-server output uses `server.v<N>` (dot-separated) instead of
|
||||
upstream's `server-v<N>` to keep version segments visually distinct in
|
||||
operator listings.
|
||||
@@ -0,0 +1,69 @@
|
||||
# `commands/keys/keys.types`
|
||||
|
||||
## Purpose
|
||||
|
||||
Shared type definitions and filename constants for every keys command in
|
||||
`@suaveplan/server-keys`. Centralising these here lets the public
|
||||
commands, the chain commands, and the I/O helpers all agree on the
|
||||
exact shape of `KeyCommandOptions`, the same set of filenames, and the
|
||||
verifier return envelope without circular imports. Tests assert against
|
||||
the same constants, so a typo in the filename convention surfaces as a
|
||||
test failure rather than as silent drift between the bins and their
|
||||
underlying commands.
|
||||
|
||||
## Features
|
||||
|
||||
- `KeysLogger` — `Pick<LoggerLike, "info" | "error">` from
|
||||
`@suaveplan/types`; every command accepts an injected logger that
|
||||
defaults to silent.
|
||||
- `KeyCommandOptions` — `{ output?, logger?, signal? }`, the option
|
||||
bag every public keys command takes.
|
||||
- `KEY_FILE_NAMES` — frozen record of the conventional filenames
|
||||
(`root.vault`, `root.pub`, `root.chain`, `server.vault`,
|
||||
`server.pub`, `server.chain`).
|
||||
- `VerifyKeyChainResult` — the structured envelope returned by
|
||||
`verifyKeyChainCommand` (`{ valid, depth, errors }`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
KEY_FILE_NAMES,
|
||||
type KeyCommandOptions,
|
||||
} from "@suaveplan/server-keys/commands/keys/keys.types";
|
||||
|
||||
const options: KeyCommandOptions = { output: "/etc/suaveplan/keys" };
|
||||
const path = `${options.output}/${KEY_FILE_NAMES.rootVault}`;
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Match on the structured verifier envelope without re-implementing the
|
||||
discrimination logic in every caller:
|
||||
|
||||
```ts
|
||||
import type { VerifyKeyChainResult } from "@suaveplan/server-keys/commands/keys/keys.types";
|
||||
|
||||
function summariseChain(r: VerifyKeyChainResult): string {
|
||||
if (r.valid) return `OK (depth=${r.depth})`;
|
||||
return `INVALID (depth=${r.depth}): ${r.errors.join("; ")}`;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface KeysLogger extends Pick<LoggerLike, "info" | "error">`
|
||||
- `interface KeyCommandOptions { output?, logger?, signal? }`
|
||||
- `const KEY_FILE_NAMES = Object.freeze({ rootVault, rootPublic,
|
||||
rootChain, serverVault, serverPublic, serverChain })`
|
||||
- `interface VerifyKeyChainResult { valid, depth, errors }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`KEY_FILE_NAMES` is `Object.freeze`-d so accidental mutation at runtime
|
||||
throws in strict mode; this is the only mutable-looking constant in the
|
||||
keys surface, and the freeze guards against bins or tests reaching in
|
||||
to override a name (which would silently break the upstream-compatible
|
||||
layout convention). The `KeysLogger` shape is narrower than the full
|
||||
`LoggerLike` so the bins can supply a console shim with just two
|
||||
methods without TypeScript complaining about missing trace/debug/warn.
|
||||
@@ -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