Initial Commit
This commit is contained in:
@@ -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.
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
5
packages/api-stack/api/api-auth/src/error-codes/index.ts
Normal file
5
packages/api-stack/api/api-auth/src/error-codes/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
AUTH_CODES,
|
||||
AuthError,
|
||||
type AuthErrorCode,
|
||||
} from "./error-codes.js";
|
||||
Reference in New Issue
Block a user