Files
suaveplan/openspec/archive/2026-05-18-enterprise-server-keys/specs/server-keys/spec.md
2026-05-27 23:29:53 +01:00

13 KiB

Capability Spec Delta: server-keys (enterprise-server-keys)

Package: @suaveplan/server-keys Status: PROPOSED

ADDED Requirements

Requirement: Top-level commander dispatcher with 17 sub-commands

The package SHALL ship a suaveplan bin that uses commander to route to all 17 sub-commands (create/roll/verify key + chain bins, viewer bins, file transport bins, license bin). Each sub-command SHALL be lazy-imported. Calling suaveplan with no arguments SHALL print help and exit 0.

Scenario: every sub-command is reachable from the dispatcher

  • WHEN suaveplan <subcommand> --help is invoked for each of the 17 sub-commands
  • THEN each prints its own help block and exits 0

Verified by: src/bin/index.test.ts (describe("suaveplan dispatcher"))


Requirement: Root-key creation bin produces encrypted vault + signed public-key file

create-root-keys SHALL accept --output <dir> (default ./keys), prompt for a new password (confirm prompt), generate a root keychain via @suaveplan/crypto, encrypt it into a vault file via @suaveplan/crypto-vault, and write a signed public-key file alongside. Failures SHALL exit with code 1 and a ServerKeysError message.

Scenario: produces vault + public-key file at the configured output path

  • WHEN createRootKeysCommand(password, { output }) is called
  • THEN output/root.vault and output/root.pub exist, vault is decryptable with password, public-key file is signature-verified by the embedded chain

Verified by: src/commands/keys/keys.test.ts::"createRootKeysCommand writes encrypted vault and signed public-key file"


Requirement: Server-key derivation bin from root vault

create-server-keys SHALL accept --root-vault <path> (default ./keys/root.vault) + --output <dir> (default ./keys), prompt for the root password and a new server password, derive a server keychain from the root master key, encrypt it into server.vault, and write a signed server.pub.

Scenario: derives server keychain from a root vault

  • WHEN createServerKeysCommand(rootVaultPath, rootPassword, serverPassword, { output }) is called after a successful create-root-keys
  • THEN server.vault is created, decryptable with serverPassword, and its public key is signed by the root keychain

Verified by: src/commands/keys/keys.test.ts::"createServerKeysCommand derives from root vault and writes server vault + public-key file"


Requirement: Server-key rotation bin bumps keychain version

roll-server-keys SHALL accept --server-vault <path> + --output <dir>, prompt for both passwords (root + current server), generate a new server keychain at version = current + 1 deterministically derived from the root master key, write server.v<N>.vault, and emit a rotation manifest.

Scenario: rotation increments keychain version

  • WHEN rollServerKeysCommand(...) is called with a current server.v1.vault
  • THEN server.v2.vault is created, decryptable, and its keychain's version === 2

Verified by: src/commands/keys/keys.test.ts::"rollServerKeysCommand bumps keychain version"


Requirement: Hierarchical chained-key bins (create-root/server, roll-server with chain)

create-root-keys-chain, create-server-keys-chain, roll-server-keys-chain, and verify-key-chain SHALL use @suaveplan/crypto-keys ChainedKeySigner to produce / verify chained public-key bundles. Root chains are self-signed; server chains are root-signed; rolled-server chains are signed by the current root. verify-key-chain SHALL accept a chain file + an explicit trusted root and report valid: boolean + walked depth + per-level error details.

Scenario: end-to-end chain — create root, derive server, verify

  • WHEN the three create commands run in sequence then verifyKeyChainCommand(chainFile, trustedRoot) is invoked
  • THEN the result has valid: true and depth === 2

Scenario: tampered chain fails verification

  • WHEN any byte of the chain file is flipped and verifyKeyChainCommand is invoked
  • THEN the result has valid: false with a populated errors array

Verified by: src/commands/keys/keys.test.ts::"end-to-end chain create + verify" AND src/commands/keys/keys.test.ts::"tampered chain fails verifyKeyChainCommand"


Requirement: Viewer bins decode keystore artifacts without mutating state

view-keys, view-vault, view-public-key-file, view-chain-public SHALL be read-only inspectors. view-keys prompts for a password and prints decrypted keychain summary; view-vault prints vault file metadata without decryption; view-public-key-file prints the public-key file's claimed identity and signature status; view-chain-public walks a chain file and prints each level's public key + signer + optional verification result.

Scenario: view-vault works without password

  • WHEN viewVaultCommand(vaultPath) is called on a valid vault
  • THEN stdout contains the vault's version, algorithm, and creation timestamp; no decryption is attempted

Verified by: src/commands/keys/keys.test.ts::"viewVaultCommand prints metadata without decryption" AND src/commands/keys/keys.test.ts::"viewKeysCommand requires password and prints keychain summary"


Requirement: File transport bins (prepare / serve / validate / stats / validate-packet)

prepare-file SHALL accept <input-file> --output <dir> --server-vault <path>, prompt for the server password, pack the file into signed packets via @suaveplan/packer, and write them under output/. serve-file SHALL read packets from a directory and reconstruct the original file. validate-file SHALL walk every packet and verify integrity + signature against the embedded signer public key. get-file-stats SHALL print packet count + estimated reconstructed size + manifest summary without verification. validate-packet SHALL validate a single packet in isolation. Every command SHALL accept --signal: AbortSignal (programmatic API) and SHALL exit cleanly on SIGINT.

Scenario: prepare then serve reconstructs the input

  • WHEN prepareFileCommand(input, opts) produces packet files and serveFileCommand(packetDir, output) then reconstructs
  • THEN the reconstructed file is byte-identical to input

Scenario: validate-file detects a tampered packet

  • WHEN any byte of any packet body is flipped and validateFileCommand(packetDir) is invoked
  • THEN the command reports the tampered packet's path and exits non-zero

Verified by: src/commands/files/files.test.ts::"prepareFile + serveFile round-trip a binary input" AND src/commands/files/files.test.ts::"validateFile detects tampered packet" AND src/commands/files/files.test.ts::"getFileStats prints manifest without verification" AND src/commands/files/files.test.ts::"validatePacket reports a single-packet failure"


Requirement: License bin wraps @suaveplan/license LicenseManager

license SHALL expose sub-commands generate, validate, parse, revoke that delegate to @suaveplan/license LicenseManager. license generate accepts a server-vault path + product/tier/expiry/feature flags; signs with the server vault's signing key; prints the resulting license key. license validate accepts a key + a public-key file (or vault); prints success/failure + decoded payload. license parse decodes without verification. license revoke writes the fingerprint to a configured RevocationStore (default: file-backed in ./keys/revocations.json).

Scenario: generate then validate round-trip

  • WHEN licenseCommand("generate", ...) produces a key and licenseCommand("validate", key, ...) is invoked
  • THEN the validation prints success: true and exits 0

Verified by: src/commands/license/license.test.ts::"license generate + validate round-trip" AND src/commands/license/license.test.ts::"license parse decodes without verification" AND src/commands/license/license.test.ts::"license revoke marks key as REVOKED on subsequent validate"


Requirement: Password prompts via @inquirer/prompts, no echo

promptForPassword(message) SHALL read a single password from stdin with no echo. promptForNewPassword(message) SHALL prompt twice and assert equality; mismatches SHALL raise ServerKeysError(SERVER_KEYS_PASSWORD_MISMATCH). Both SHALL respect a --no-tty test mode where the password is read from an inputStream injected via PromptOptions.

Scenario: confirm prompt mismatch raises typed error

  • WHEN promptForNewPassword("test", { inputStream: stream(["one", "two"]) }) is awaited
  • THEN the promise rejects with ServerKeysError(SERVER_KEYS_PASSWORD_MISMATCH)

Verified by: src/prompt/prompt.test.ts::"promptForPassword reads stdin with no echo" AND src/prompt/prompt.test.ts::"promptForNewPassword rejects mismatched confirmation"


Requirement: ServerKeysError extends SuaveplanError with SERVER_KEYS_* code registry

The package SHALL define ServerKeysError extends SuaveplanError<ServerKeysErrorCode> with codes registered via defineCodes("SERVER_KEYS", [...]). Codes SHALL include SERVER_KEYS_VAULT_NOT_FOUND, SERVER_KEYS_VAULT_DECRYPT_FAILED, SERVER_KEYS_PASSWORD_MISMATCH, SERVER_KEYS_CHAIN_VERIFY_FAILED, SERVER_KEYS_PACKET_INTEGRITY_FAILED, SERVER_KEYS_FILE_NOT_FOUND, SERVER_KEYS_INVALID_INPUT, SERVER_KEYS_LICENSE_FAILED, SERVER_KEYS_UNKNOWN_COMMAND, SERVER_KEYS_ABORTED. Every registered code SHALL be raised by at least one production call site.

Scenario: every SERVER_KEYS_* code is exercised

  • WHEN the spec-conformance test enumerates SERVER_KEYS_CODES and exercises each code's production path
  • THEN every code appears in the assertions

Verified by: src/server-keys.spec-conformance.test.ts::"every SERVER_KEYS_* code is thrown from a production path"


Requirement: Zod boundary validation for every CLI option bag

The package SHALL Zod-validate every commander action's options bag (output paths, vault paths, signal/abort flags, file paths, license-bin sub-options). Invalid input SHALL raise ServerKeysError(SERVER_KEYS_INVALID_INPUT) with the ZodError as cause. CLI invocation SHALL exit non-zero on validation failure with a one-line human-readable summary printed to stderr.

Scenario: invalid --output path rejected

  • WHEN a command receives --output pointing at a non-writable path
  • THEN the command exits with code 1 and stderr contains SERVER_KEYS_INVALID_INPUT

Verified by: src/server-keys.spec-conformance.test.ts::"Zod validation rejects unwritable --output path"


Requirement: OpenTelemetry counter + histogram per command

The package SHALL register counter server_keys.command_total{command} and histogram server_keys.command_duration_ms{command} via @opentelemetry/api. Every command (runFunction wrapper) SHALL increment and record on completion regardless of success/failure (attribute command.outcome: "success"|"error").

Scenario: counter and histogram emit per command

  • WHEN any command runs against a real in-memory MeterProvider
  • THEN both instruments have at least one observation with the correct command attribute

Verified by: src/server-keys.spec-conformance.test.ts::"OTel command counter + histogram emit per run"


Requirement: Universal-tier discipline — sibling docs, subpath exports, no banned patterns

The package SHALL ship a sibling .md (≥200 words, Purpose/Features/Usage:Basic/Usage:Advanced/API Reference/Implementation Notes) for every implementation .ts. package.json SHALL declare subpath exports for every command + prompt module. Feature barrels SHALL use named re-exports only (no export *). Test files SHALL import runner primitives from @suaveplan/testing/runner. The src/ SHALL contain no @deprecated / TODO / FIXME / ts-ignore / .skip / console.log / bare any.

Scenario: package shape is clean

  • WHEN tests/dep-graph.test.ts inspects file tree, package.json, and barrels
  • THEN every assertion holds

Verified by: tests/dep-graph.test.ts::"every implementation .ts has a sibling .md" AND tests/dep-graph.test.ts::"subpath exports cover every command module" AND tests/dep-graph.test.ts::"no export * in any feature barrel"


Requirement: 100% coverage with integration tests against the real filesystem

The package SHALL enforce 100/100/100/100 statements/branches/functions/lines via Istanbul. Integration tests SHALL exercise the full prepare-file → serve-file round-trip against a real temp directory and a real @suaveplan/storage-file instance — no mocks of crypto, packer, or storage. Property tests SHALL cover keychain round-trips and packet manifest round-trips via fast-check.

Scenario: coverage gate

  • WHEN bunx turbo test:coverage --filter=@suaveplan/server-keys runs
  • THEN all four thresholds are 100

Verified by: tests/integration/prepare-serve-roundtrip.test.ts::"prepares and reconstructs a binary file against a real temp dir" AND tests/property.test.ts::"chain encode/decode round-trips for arbitrary depth" AND tests/property.test.ts::"packet manifest round-trips arbitrary payloads"