first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
# @suaveplan/api-auth-anonymous — trivial session resolver for @suaveplan/api: always returns null Principal. Used as the tail of resolver chains for fully public APIs and as the foundation conformance seed for every other session-resolver in the stack.
Stage-4 trivial session resolver for `@suaveplan/api`.
Always returns `null` for every request. Use this as the tail of resolver
chains for public endpoints, and as the canonical adapter for fully public
APIs. The package also serves as the foundation conformance seed exercised by
every other concrete session-resolver in Stages 4 / 6 / 8.
<!-- begin:auto-docs -->
## Documentation tree
- [adapter](./src/adapter/adapter.md) — Canonical `AuthAdapter` for fully public APIs. Pairs the trivial anonymous resolver with the minimal adapter metadata t…
- [adapter](./src/adapter/adapter.md) — Canonical `AuthAdapter` for fully public APIs. Pairs the trivial anonymous resolver with the minimal adapter metadata t…
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/api-auth-anonymous`. The anonymous resolver has no per-request failure modes…
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/api-auth-anonymous`. The anonymous resolver has no per-request failure modes…
- [resolver](./src/resolver/resolver.md) — Factory for the trivial `SessionResolver` that always returns `null`. This is the canonical tail-of-chain resolver ever…
- [resolver](./src/resolver/resolver.md) — Factory for the trivial `SessionResolver` that always returns `null`. This is the canonical tail-of-chain resolver ever…
<!-- end:auto-docs -->
## Why does "no auth" need its own package?
Per the API foundation design doc §2.3, every host configures a session
resolver — even fully public APIs. Configuring `ANONYMOUS_AUTH_ADAPTER`
explicitly makes the "no auth" choice visible in `host.json` and lets the rest
of the host (RBAC gate, error envelope, OpenAPI emission) operate uniformly:
the principal is simply `null`, never `undefined`.
## Surface
| Subpath | Export |
|----------------|---------------------------------------------------|
| `./resolver` | `createAnonymousResolver(opts?): SessionResolver` |
| `./adapter` | `ANONYMOUS_AUTH_ADAPTER: AuthAdapter` |
| `./error-codes`| `AUTH_ANON_CODES`, `AuthAnonError` |
## Usage
```ts
import { ANONYMOUS_AUTH_ADAPTER } from "@suaveplan/api-auth-anonymous/adapter";
host.registerAuth(ANONYMOUS_AUTH_ADAPTER);
// every request now produces `principal: null`; public routes proceed,
// non-public routes 401 via the host's `publicRoutes` decision.
```
For mixed chains, see `createAnonymousResolver()` + `SessionResolverChain`
in `@suaveplan/api-auth/chain`.
## Conformance
The resolver passes `runAuthSessionConformance` (null-only mode) from
`@suaveplan/api-auth/testing`. See
`src/resolver/resolver.test.ts`.
## Errors
`AUTH_ANON.INVALID_OPTIONS` — thrown only when the factory is handed a
non-object or unknown-key options bag. No per-request error paths exist by
design.

View File

@@ -0,0 +1,72 @@
# `adapter` — `ANONYMOUS_AUTH_ADAPTER`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Canonical `AuthAdapter` for fully public APIs. Pairs the trivial anonymous
resolver with the minimal adapter metadata the host needs to wire it into the
per-request pipeline. The adapter omits `securityScheme` on purpose:
`validateAuthAdapter` permits a missing scheme for the anonymous topology so
the emitted OpenAPI document contains no `security` entry on routes — the
correct shape for "no authentication required".
## Features
- Stateless singleton — safe to import once per host process.
- `csrf: false` baked in: anonymous traffic has no session to protect; the
generic CSRF middleware in `@suaveplan/api-auth/csrf-policy` skips routes
whose adapter pins `csrf: false`.
- Validates cleanly against `validateAuthAdapter` — no runtime error at host
registration time despite the omitted security scheme.
## Usage: Basic
```ts
import { ANONYMOUS_AUTH_ADAPTER } from "@suaveplan/api-auth-anonymous/adapter";
import { validateAuthAdapter } from "@suaveplan/api-auth";
const adapter = validateAuthAdapter(ANONYMOUS_AUTH_ADAPTER);
host.registerAuth(adapter);
```
## Usage: Advanced
For a chain that mixes typed resolvers with the anonymous tail, do not use
`ANONYMOUS_AUTH_ADAPTER` directly — assemble a `SessionResolverChain` so the
typed resolvers fire first:
```ts
import { SessionResolverChain } from "@suaveplan/api-auth";
import { createAnonymousResolver } from "@suaveplan/api-auth-anonymous/resolver";
const chain = new SessionResolverChain([
jwtResolver,
createAnonymousResolver(),
]);
const adapter = {
resolver: chain,
securityScheme: jwtSecurityScheme, // the typed resolver still emits its scheme
} as const;
```
## API Reference
| Symbol | Kind | Description |
|------------------------------|----------|------------------------------------------------|
| `ANONYMOUS_AUTH_ADAPTER` | const | `AuthAdapter` with anonymous resolver, no scheme, `csrf: false`. |
## Implementation Notes
- The constant is constructed once at module load and reused across requests
— the resolver itself is documented stateless, so a single instance is safe
to fan out across concurrent requests.
- `csrf: false` is deliberate. Anonymous requests carry no session cookie, so
applying CSRF defence would reject legitimate public traffic. Hosts that
want to additionally rate-limit anonymous traffic should layer
`api-rate-limit` (Stage 8) on top — that is not this package's concern.
- Test coverage exercises (a) `validateAuthAdapter(ANONYMOUS_AUTH_ADAPTER)`
returns the adapter unchanged, (b) the embedded resolver still resolves
null, and (c) the singleton survives repeated import without re-allocation.

View File

@@ -0,0 +1,77 @@
# `error-codes` — `AUTH_ANON.*` registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Stable error-code registry for `@suaveplan/api-auth-anonymous`. The anonymous
resolver has no per-request failure modes — every request resolves to `null` by
construction. The single `AUTH_ANON.INVALID_OPTIONS` code covers the only thing
that can fail, which is rejecting malformed options handed to
`createAnonymousResolver(opts)` at construction time. Keeping a dedicated
code-namespace per package (rather than re-using `AUTH.*`) means consumers can
disambiguate anonymous-side problems from general session-resolver issues when
multiple resolvers are mounted on the same chain.
## Features
- Defined via `defineCodes("AUTH_ANON", [...])` from `@suaveplan/error` so the
literal value is always `"AUTH_ANON.INVALID_OPTIONS"`.
- `AuthAnonError extends SuaveplanError<AuthAnonErrorCode>` — instance checks
narrow `error.code` to the registry type at the type level.
- Carries `authAnonCode` as a non-discriminating mirror so adapter code can
branch on `err.authAnonCode === AUTH_ANON_CODES.INVALID_OPTIONS` without
re-typing the parent class.
## Usage: Basic
```ts
import { AUTH_ANON_CODES, AuthAnonError } from "@suaveplan/api-auth-anonymous/error-codes";
throw new AuthAnonError(
AUTH_ANON_CODES.INVALID_OPTIONS,
"createAnonymousResolver: options is not an object"
);
```
## Usage: Advanced
Wrap a parser failure (e.g. zod) and forward its details into the
`context.issues` slot so observability pipelines can render the schema diff:
```ts
import { AUTH_ANON_CODES, AuthAnonError } from "@suaveplan/api-auth-anonymous/error-codes";
import z from "zod";
const OptionsSchema = z.object({}).strict();
export function parseOptions(input: unknown): Record<string, never> {
const out = OptionsSchema.safeParse(input);
if (!out.success) {
throw new AuthAnonError(
AUTH_ANON_CODES.INVALID_OPTIONS,
"Anonymous resolver options failed schema validation",
{ context: { issues: out.error.issues } }
);
}
return out.data;
}
```
## API Reference
| Symbol | Kind | Description |
|----------------------|----------|------------------------------------------------------------|
| `AUTH_ANON_CODES` | const | `{ INVALID_OPTIONS: "AUTH_ANON.INVALID_OPTIONS" }` |
| `AuthAnonErrorCode` | type | Union of values in `AUTH_ANON_CODES`. |
| `AuthAnonError` | class | `extends SuaveplanError<AuthAnonErrorCode>`. |
## Implementation Notes
- The single code is deliberate. The resolver is documented to never reject a
request; adding speculative codes for "unrecognised credential" or
"principal lookup failed" would be lying about a contract that the
implementation cannot break by construction.
- Tested in `error-codes.test.ts` for both string value and instance shape.

View File

@@ -0,0 +1,79 @@
# `resolver` — `createAnonymousResolver`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Factory for the trivial `SessionResolver` that always returns `null`. This is
the canonical tail-of-chain resolver every public-API host configures: it
makes the "no auth" choice explicit in `host.json` and lets the rest of the
host (RBAC gate, error envelope, OpenAPI emission) operate uniformly because
`principal` is `null` not `undefined`. The factory shape exists — rather than
exposing a constant — so future option flags (e.g. `logAnonymousAccess`) can
be threaded in without a breaking surface change.
## Features
- Always resolves `null` — no header inspection, no network I/O, no state.
- Idempotent `dispose()` no-op (resolver holds no resources).
- Strict zod options schema rejects non-object input and unknown keys with
`AUTH_ANON.INVALID_OPTIONS`.
- Returned type is `SessionResolver` from `@suaveplan/api-auth/resolver` so
it composes directly into `SessionResolverChain`.
## Usage: Basic
```ts
import { createAnonymousResolver } from "@suaveplan/api-auth-anonymous/resolver";
const resolver = createAnonymousResolver();
// Inside a request pipeline:
const principal = await resolver.resolve(request); // → null
await resolver.dispose(); // safe to call again
```
## Usage: Advanced
Compose as the tail of a resolver chain so a JWT-typed resolver runs first
and the anonymous resolver supplies the `null` short-circuit for unrecognised
requests on public routes:
```ts
import { SessionResolverChain } from "@suaveplan/api-auth";
import { createAnonymousResolver } from "@suaveplan/api-auth-anonymous/resolver";
const chain = new SessionResolverChain([
jwtResolver, // recognises Bearer tokens, else returns null
apiKeyResolver, // recognises x-api-key, else returns null
createAnonymousResolver(), // tail: always null
]);
const principal = await chain.resolve(request);
// principal is now `Principal | null`. The host applies its `publicRoutes`
// decision: a non-public route 401s; a public route proceeds with null.
```
## API Reference
| Symbol | Kind | Description |
|------------------------------|----------|-------------------------------------------------------|
| `createAnonymousResolver` | function | `(opts?: AnonymousResolverOptions) => SessionResolver`. |
| `AnonymousResolverOptions` | type | Reserved-for-future-flags options bag. |
## Implementation Notes
- The resolver returns `Promise<null>` literally rather than the wider
`Promise<Principal | null>` so type narrowing in tests can use
`expectTypeOf<...>().toEqualTypeOf<Promise<null>>()`. The chain widens it
back to `Principal | null` at the boundary.
- `dispose()` is a `Promise<void>` not just any promise — the conformance
harness asserts the resolved value is `undefined` so this is load-bearing.
- The factory is the public surface (not a const) because future flags must
be addable without a breaking change to call sites. Even today, passing
`{}` is the canonical call.
- No OTel instrumentation is emitted from this resolver. Anonymous-access
observability lives at the host layer (per-request span tag) — adding
counters here would force a foundation-tier package to depend on
`@suaveplan/opentelemetry`, which is undesirable for a hot path.