Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,71 @@
# `errors`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module hosts the stable error-code registry and the `SrpError` class for the `@suaveplan/srp` package. Every code listed here is thrown by at least one production path under `src/` — the audit test in `errors.test.ts` enforces that contract so dead codes cannot accumulate. Consumers branch on `error.code` (a literal-typed `SrpErrorCode`) rather than parsing `error.message`, which keeps error-handling resilient across releases.
## Features
- Frozen `SRP_CODES` registry via `defineCodes("SRP", […])`.
- Literal-typed `SrpErrorCode` union derived from the registry.
- `SrpError extends SuaveplanError<SrpErrorCode>` carrying the code on the instance.
- `srpError(code, message, options)` factory that prefixes the code into the message string so log scrapers and `expect(...).toThrow(/SRP\./)` matchers find a stable substring without inspecting `.code`.
- Each `SrpError` instance is `instanceof SuaveplanError`, so the global error-classification surface (OTel span events, structured logging) sees it without per-package wiring.
## Usage: Basic Example
```ts
import { SRP_CODES, SrpError, srpError } from "@suaveplan/srp/errors";
throw srpError(SRP_CODES.AUTH_FAILED, "client M1 did not match expected M1");
```
## Usage: Advanced Example
```ts
import { SrpError, SRP_CODES } from "@suaveplan/srp/errors";
try {
runSrpHandshake();
} catch (err) {
if (err instanceof SrpError && err.code === SRP_CODES.PUBLIC_KEY_INVALID) {
// RFC 5054 §2.5.4 — abort the session and rotate ephemeral keys.
return;
}
throw err;
}
```
## API Reference
### `SRP_CODES`
Frozen record mapping each reason to `"SRP.<REASON>"`. Six codes:
- `SRP.INVALID_PARAMS` — malformed `SrpParams`, oversized `PAD` input, or other structural validation failure.
- `SRP.PUBLIC_KEY_INVALID` — `A ≡ 0 mod N`, `B ≡ 0 mod N`, or `u ≡ 0 mod N`.
- `SRP.AUTH_FAILED` — server rejected the client's `M1`.
- `SRP.MUTUAL_AUTH_FAILED` — client rejected the server's `M2`.
- `SRP.GROUP_UNSUPPORTED` — group parameters fail structural validation.
- `SRP.HASH_UNSUPPORTED` — hash algorithm outside `{"SHA256", "SHA1"}`.
### `SrpErrorCode`
Literal-typed union of every value in `SRP_CODES`.
### `class SrpError extends SuaveplanError<SrpErrorCode>`
Carries `.code: SrpErrorCode`. Constructor accepts `(code, message, options?)`. The `options.cause` field, when an `Error`, threads through `SuaveplanError`'s cause-chain machinery.
### `srpError(code, message, options?)`
Factory returning an `SrpError`. Prefixes the message with `${code}:` for stable substring matching. `options.cause` accepts any `unknown` and is only attached when it is an `Error` instance.
## Implementation Notes
The registry is exhaustively audited at test time: `errors.test.ts` walks `SRP_CODES`, scans `src/` for a matching throw site for each code, and fails if any code is unreachable. This forces every new code to either ship with a real production path or be removed from the registry. The audit deliberately excludes `*.test.ts` files so test-only fixtures cannot satisfy the contract.
`srpError` prefixes the code into the message because `SuaveplanError`'s base constructor uses the `messageKey` argument for i18n resolution; in this package every code is internal and has a short English-only message, so prefixing keeps the substring stable while preserving the structured `.code` field. Callers that want a different presentation MAY construct `SrpError` directly.

View File

@@ -0,0 +1,103 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { SuaveplanError } from "@suaveplan/error";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SRP_CODES, SrpError, srpError } from "./errors.js";
const SRC_ROOT = join(import.meta.dirname, "..");
describe("SRP_CODES", () => {
it("contains exactly the 6 documented codes", { timeout: 5000 }, () => {
const keys = Object.keys(SRP_CODES).sort();
expect(keys).toEqual([
"AUTH_FAILED",
"GROUP_UNSUPPORTED",
"HASH_UNSUPPORTED",
"INVALID_PARAMS",
"MUTUAL_AUTH_FAILED",
"PUBLIC_KEY_INVALID",
]);
for (const k of keys) {
expect(SRP_CODES[k as keyof typeof SRP_CODES]).toBe(`SRP.${k}`);
}
});
it("every SRP code reachable from a production path", {
timeout: 5000,
}, () => {
// Recursively read every .ts under src/ excluding test files, fixtures,
// and *.types.ts. For each registered code, assert at least one
// production file mentions the code literal.
const sources = readSrcSources(SRC_ROOT);
for (const code of Object.values(SRP_CODES)) {
const reason = code.split(".")[1];
const found = sources.some(({ body }) => body.includes(reason!));
expect(found).toBe(true);
}
});
});
describe("SrpError", () => {
it("instances carry the SRP.* code literal", { timeout: 5000 }, () => {
const err = srpError(SRP_CODES.AUTH_FAILED, "test");
expect(err).toBeInstanceOf(SrpError);
expect(err).toBeInstanceOf(SuaveplanError);
expect(err.code).toBe("SRP.AUTH_FAILED");
expect(err.message).toContain("SRP.AUTH_FAILED");
});
it("srpError threads through Error cause", { timeout: 5000 }, () => {
const cause = new Error("inner");
const err = srpError(SRP_CODES.INVALID_PARAMS, "outer", { cause });
expect((err as unknown as { cause: Error }).cause).toBe(cause);
});
it("SrpError constructor uses default empty options", {
timeout: 5000,
}, () => {
const err = new SrpError(SRP_CODES.INVALID_PARAMS, "no opts");
expect(err.code).toBe("SRP.INVALID_PARAMS");
});
it("srpError called without options argument", { timeout: 5000 }, () => {
const err = srpError(SRP_CODES.AUTH_FAILED, "no opts");
expect(err.code).toBe("SRP.AUTH_FAILED");
});
it("srpError ignores non-Error cause silently", { timeout: 5000 }, () => {
const err = srpError(SRP_CODES.INVALID_PARAMS, "outer", {
cause: "string",
});
expect(err.code).toBe("SRP.INVALID_PARAMS");
});
});
type SrcSource = { path: string; body: string };
function readSrcSources(root: string): SrcSource[] {
const out: SrcSource[] = [];
const fs = require("node:fs") as typeof import("node:fs");
function walk(dir: string): void {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (
entry.name === "__fixtures__" ||
entry.name === "__benchmarks__"
) {
continue;
}
walk(full);
} else if (
entry.name.endsWith(".ts") &&
!entry.name.endsWith(".test.ts") &&
!entry.name.endsWith(".types.ts") &&
!entry.name.endsWith(".bench.ts")
) {
out.push({ path: full, body: readFileSync(full, "utf8") });
}
}
}
walk(root);
return out;
}

View File

@@ -0,0 +1,62 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable, registered error codes for `@suaveplan/srp`.
*
* Every code below is thrown by at least one production path in `src/`
* (audited by `errors.test.ts`). Codes are stable identifiers — message
* text may change between releases but the literal string `SRP.<REASON>`
* never will.
*
* - `INVALID_PARAMS` — Zod boundary rejected `SrpParams`, oversized
* `PAD` input, or any other malformed-input failure that is not a
* `PUBLIC_KEY_INVALID` per RFC 5054 §2.5.4.
* - `PUBLIC_KEY_INVALID` — `A mod N == 0`, `B mod N == 0`, or
* `u ≡ 0 mod N`. The peer MUST abort per RFC 5054 §2.5.4.
* - `AUTH_FAILED` — Server rejected the client's `M1` proof (wrong
* password, replay, tampered transport).
* - `MUTUAL_AUTH_FAILED` — Client rejected the server's `M2` proof.
* Indicates a malicious or buggy server.
* - `GROUP_UNSUPPORTED` — Caller passed a group whose parameters fail
* structural validation (e.g. `N.byteLength` not a multiple of 4,
* `g` ≥ `N`).
* - `HASH_UNSUPPORTED` — Caller requested a hash algorithm outside
* `{"SHA256", "SHA1"}`.
*/
export const SRP_CODES = defineCodes("SRP", [
"INVALID_PARAMS",
"PUBLIC_KEY_INVALID",
"AUTH_FAILED",
"MUTUAL_AUTH_FAILED",
"GROUP_UNSUPPORTED",
"HASH_UNSUPPORTED",
] as const);
/** Literal-typed union of every registered code. */
export type SrpErrorCode = (typeof SRP_CODES)[keyof typeof SRP_CODES];
/** Base class for every error thrown by `@suaveplan/srp`. */
export class SrpError extends SuaveplanError<SrpErrorCode> {
constructor(
code: SrpErrorCode,
message: string,
options: { cause?: Error } = {}
) {
super(code, message, options);
}
}
/**
* Construct an `SrpError` with the code prefixed onto the message so
* `expect(...).toThrow(/SRP\./)` and log scrapers find a stable substring
* without inspecting `.code`.
*/
export function srpError(
code: SrpErrorCode,
message: string,
options: { cause?: unknown } = {}
): SrpError {
const opts: { cause?: Error } = {};
if (options.cause instanceof Error) opts.cause = options.cause;
return new SrpError(code, `${code}: ${message}`, opts);
}

View File

@@ -0,0 +1,6 @@
export type { SrpErrorCode } from "./errors.js";
export {
SRP_CODES,
SrpError,
srpError,
} from "./errors.js";