Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,84 @@
# `error-codes` — `AUTH_CODES` registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable, namespaced error-code registry for the `@suaveplan/api-auth`
package and the concrete `AuthError` class. Every code is reachable from a
production throw site somewhere in the package (chain construction, chain
execution, adapter registration, or principal-shape validation). The registry
is frozen via `defineCodes("AUTH", […])` so accidental drift between thrown
codes and the documented set is impossible.
Consumers — including downstream concrete resolvers in Stages 4/6/8 such as
`api-auth-jwt`, `api-auth-anonymous`, and `api-auth-bearer-static` — branch on
the string literal (`"AUTH.RESOLVER_THREW"` etc.) or compare to the constant
(`AUTH_CODES.RESOLVER_THREW`). The literal form is preferred at the API
boundary; the constant is preferred inside the package itself so the linter
flags typos as type errors.
## Features
- Frozen string-literal union of all four codes.
- `AuthError` extends `SuaveplanError<AuthErrorCode>` so `instanceof AuthError`
narrows `code` correctly.
- Cause-chain propagation: the optional `options.cause` field is forwarded to
the base class.
- Public constructor (the base class is `protected`).
## Usage: Basic
```ts
import { AUTH_CODES, AuthError } from "@suaveplan/api-auth/error-codes";
if (chain.length === 0) {
throw new AuthError(AUTH_CODES.CHAIN_EMPTY, "at least one resolver required");
}
```
## Usage: Advanced
Re-throw a resolver error with a cause chain and inspect it downstream:
```ts
import { AUTH_CODES, AuthError } from "@suaveplan/api-auth/error-codes";
try {
await resolver.resolve(req);
} catch (cause) {
throw new AuthError(AUTH_CODES.RESOLVER_THREW, "resolver A threw", {
cause,
context: { resolverIndex: 0 },
});
}
```
```ts
import { AuthError } from "@suaveplan/api-auth/error-codes";
if (err instanceof AuthError && err.code === "AUTH.RESOLVER_THREW") {
logger.warn("upstream session resolver failed", { cause: err.cause });
}
```
## API Reference
| Symbol | Kind | Description |
|----------------|--------|-----------------------------------------------------------------|
| `AUTH_CODES` | const | Frozen lookup `{ RESOLVER_THREW, CHAIN_EMPTY, … }`. |
| `AuthErrorCode`| type | Literal union of the four code strings. |
| `AuthError` | class | Concrete `SuaveplanError` subclass narrowed to `AuthErrorCode`. |
## Implementation Notes
- `defineCodes("AUTH", [...] as const)` produces an object literal with
string-literal value types, which is what gives `AuthErrorCode` its narrow
union. Removing `as const` would widen the type to `string`.
- The class field `authCode` mirrors `code` for ergonomic narrowing on
pre-instantiated error objects (some test runners obscure the inherited
`code` field via Proxy wrappers).
- Code values follow the `AUTH.<UPPER_SNAKE>` template enforced by
`@suaveplan/api-types`' `ErrorCode` template literal type.

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SessionResolverChain } from "../chain/chain.js";
import { validateSecurityScheme } from "../security-scheme/security-scheme.js";
import { validateAuthAdapter } from "../session-adapter/session-adapter.js";
import { AUTH_CODES } from "./error-codes.js";
/**
* Conformance — every AUTH_CODES entry MUST be reachable from a production
* throw site somewhere in this package. The test enumerates the registry and
* asserts a throw for each code.
*/
describe("AUTH_CODES — every code reachable from production path", () => {
it("CHAIN_EMPTY thrown by SessionResolverChain construction", {
timeout: 5000,
}, () => {
expect(() => new SessionResolverChain([])).toThrowError();
try {
new SessionResolverChain([]);
} catch (err) {
expect((err as { code: string }).code).toBe(AUTH_CODES.CHAIN_EMPTY);
}
});
it("RESOLVER_THREW thrown by chain when underlying resolver throws", {
timeout: 5000,
}, async () => {
const chain = new SessionResolverChain([
{
resolve: () => {
throw new Error("boom");
},
dispose: async () => undefined,
},
]);
await expect(
chain.resolve(new Request("https://example.test"))
).rejects.toMatchObject({ code: AUTH_CODES.RESOLVER_THREW });
});
it("SECURITY_SCHEME_MISSING thrown when adapter omits scheme", {
timeout: 5000,
}, () => {
expect(() => validateSecurityScheme(undefined)).toThrowError();
try {
validateAuthAdapter({
resolver: {
resolve: async () => null,
dispose: async () => undefined,
},
securityScheme: undefined as never,
});
} catch (err) {
expect((err as { code: string }).code).toBe(
AUTH_CODES.SECURITY_SCHEME_MISSING
);
}
});
it("PRINCIPAL_INVALID_SHAPE thrown when resolver returns malformed Principal", {
timeout: 5000,
}, async () => {
const chain = new SessionResolverChain([
{
// deliberately wrong shape — missing scopes + claims
resolve: async () => ({ id: "p_1" }) as unknown as null,
dispose: async () => undefined,
},
]);
await expect(
chain.resolve(new Request("https://example.test"))
).rejects.toMatchObject({
code: AUTH_CODES.PRINCIPAL_INVALID_SHAPE,
});
});
});

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AUTH_CODES, AuthError, type AuthErrorCode } from "./error-codes.js";
describe("AUTH_CODES registry", () => {
it("contains exactly the four documented codes", { timeout: 5000 }, () => {
expect(Object.values(AUTH_CODES).sort()).toEqual(
[
"AUTH.CHAIN_EMPTY",
"AUTH.PRINCIPAL_INVALID_SHAPE",
"AUTH.RESOLVER_THREW",
"AUTH.SECURITY_SCHEME_MISSING",
].sort()
);
});
it("every code value follows the AUTH.<UPPER_SNAKE> template", {
timeout: 5000,
}, () => {
for (const value of Object.values(AUTH_CODES)) {
expect(value).toMatch(/^AUTH\.[A-Z_]+$/);
}
});
it("AuthError narrows its code to AuthErrorCode", { timeout: 5000 }, () => {
const err = new AuthError(AUTH_CODES.CHAIN_EMPTY, "boom");
const code: AuthErrorCode = err.code;
expect(code).toBe("AUTH.CHAIN_EMPTY");
expect(err.authCode).toBe("AUTH.CHAIN_EMPTY");
expect(err).toBeInstanceOf(AuthError);
expect(err.message).toBe("boom");
});
it("AuthError carries the optional cause", { timeout: 5000 }, () => {
const cause = new Error("upstream");
const err = new AuthError(AUTH_CODES.RESOLVER_THREW, "wrap", { cause });
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,43 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-auth`.
*
* Every public failure path raises an {@link AuthError} carrying one of these
* namespaced `AUTH.*` codes. Consumers branch on the string value (e.g.
* `error.code === AUTH_CODES.RESOLVER_THREW`).
*
* The 4 codes cover:
* - Resolver throw surfaced by the chain (`RESOLVER_THREW`).
* - Empty chain rejected at construction (`CHAIN_EMPTY`).
* - Principal returned by a resolver that fails structural validation
* (`PRINCIPAL_INVALID_SHAPE`).
* - An `AuthAdapter` registered without a `SecuritySchemeContribution`
* (`SECURITY_SCHEME_MISSING`).
*/
export const AUTH_CODES = defineCodes("AUTH", [
"RESOLVER_THREW",
"CHAIN_EMPTY",
"PRINCIPAL_INVALID_SHAPE",
"SECURITY_SCHEME_MISSING",
] as const);
export type AuthErrorCode = (typeof AUTH_CODES)[keyof typeof AUTH_CODES];
/**
* Concrete error class for the api-auth package. Extends {@link SuaveplanError}
* so `instanceof AuthError` narrows `code` to {@link AuthErrorCode}.
*/
export class AuthError extends SuaveplanError<AuthErrorCode> {
public readonly authCode: AuthErrorCode;
public constructor(
code: AuthErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.authCode = code;
}
}

View File

@@ -0,0 +1,5 @@
export {
AUTH_CODES,
AuthError,
type AuthErrorCode,
} from "./error-codes.js";