Initial Commit
This commit is contained in:
60
packages/api-stack/api/api-auth-anonymous/README.md
Normal file
60
packages/api-stack/api/api-auth-anonymous/README.md
Normal 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.
|
||||
|
||||
76
packages/api-stack/api/api-auth-anonymous/package.json
Normal file
76
packages/api-stack/api/api-auth-anonymous/package.json
Normal file
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"name": "@suaveplan/api-auth-anonymous",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Stage-4 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.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./resolver": {
|
||||
"import": {
|
||||
"types": "./dist/resolver/resolver.d.ts",
|
||||
"default": "./dist/resolver/resolver.js"
|
||||
}
|
||||
},
|
||||
"./adapter": {
|
||||
"import": {
|
||||
"types": "./dist/adapter/adapter.d.ts",
|
||||
"default": "./dist/adapter/adapter.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/api-auth": "workspace:^",
|
||||
"@suaveplan/api-types": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,35 @@
|
||||
import { validateAuthAdapter } from "@suaveplan/api-auth";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { ANONYMOUS_AUTH_ADAPTER } from "./adapter.js";
|
||||
|
||||
describe("ANONYMOUS_AUTH_ADAPTER", () => {
|
||||
it("validates without a securityScheme (anonymous topology)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = validateAuthAdapter(ANONYMOUS_AUTH_ADAPTER);
|
||||
expect(out).toBe(ANONYMOUS_AUTH_ADAPTER);
|
||||
expect(out.securityScheme).toBeUndefined();
|
||||
});
|
||||
|
||||
it("pins csrf:false (no session to protect)", { timeout: 5000 }, () => {
|
||||
expect(ANONYMOUS_AUTH_ADAPTER.csrf).toBe(false);
|
||||
});
|
||||
|
||||
it("embedded resolver resolves null for arbitrary requests", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const result = await ANONYMOUS_AUTH_ADAPTER.resolver.resolve(
|
||||
new Request("https://example.test/")
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("dispose on embedded resolver is idempotent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await ANONYMOUS_AUTH_ADAPTER.resolver.dispose();
|
||||
await expect(
|
||||
ANONYMOUS_AUTH_ADAPTER.resolver.dispose()
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { AuthAdapter } from "@suaveplan/api-auth";
|
||||
import { createAnonymousResolver } from "../resolver/resolver.js";
|
||||
|
||||
/**
|
||||
* Canonical `AuthAdapter` composing the anonymous resolver. Omits
|
||||
* `securityScheme` deliberately — anonymous-only hosts emit an OpenAPI
|
||||
* document with no `security` entry on routes, and `validateAuthAdapter`
|
||||
* permits this exact topology (the "no auth configured" host).
|
||||
*
|
||||
* Use this constant when wiring a fully public API. For mixed chains (e.g.
|
||||
* JWT + anonymous tail), call `createAnonymousResolver()` and assemble the
|
||||
* chain via `SessionResolverChain` instead.
|
||||
*/
|
||||
export const ANONYMOUS_AUTH_ADAPTER: AuthAdapter = {
|
||||
resolver: createAnonymousResolver(),
|
||||
csrf: false,
|
||||
};
|
||||
@@ -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.
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AUTH_ANON_CODES, AuthAnonError } from "./error-codes.js";
|
||||
|
||||
describe("AUTH_ANON_CODES", () => {
|
||||
it("exposes the INVALID_OPTIONS literal", { timeout: 5000 }, () => {
|
||||
expect(AUTH_ANON_CODES.INVALID_OPTIONS).toBe(
|
||||
"AUTH_ANON.INVALID_OPTIONS"
|
||||
);
|
||||
});
|
||||
|
||||
it("AuthAnonError mirrors code into authAnonCode", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new AuthAnonError(
|
||||
AUTH_ANON_CODES.INVALID_OPTIONS,
|
||||
"bad options"
|
||||
);
|
||||
expect(err).toBeInstanceOf(AuthAnonError);
|
||||
expect(err.code).toBe(AUTH_ANON_CODES.INVALID_OPTIONS);
|
||||
expect(err.authAnonCode).toBe(AUTH_ANON_CODES.INVALID_OPTIONS);
|
||||
expect(err.message).toBe("bad options");
|
||||
});
|
||||
|
||||
it("AuthAnonError accepts options bag", { timeout: 5000 }, () => {
|
||||
const err = new AuthAnonError(
|
||||
AUTH_ANON_CODES.INVALID_OPTIONS,
|
||||
"with ctx",
|
||||
{ context: { reason: "not-an-object" } }
|
||||
);
|
||||
expect(err.context).toEqual({ reason: "not-an-object" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/api-auth-anonymous`.
|
||||
*
|
||||
* The anonymous resolver has effectively no runtime failure modes — every
|
||||
* request resolves to `null` by design. The single code covers the only thing
|
||||
* that can fail: invalid factory options supplied at construction time.
|
||||
*
|
||||
* Codes are namespaced `AUTH_ANON.*` and produced via `defineCodes` so the
|
||||
* full literal value is `"AUTH_ANON.INVALID_OPTIONS"`. Consumers branch on
|
||||
* the constant rather than the raw string.
|
||||
*/
|
||||
export const AUTH_ANON_CODES = defineCodes("AUTH_ANON", [
|
||||
"INVALID_OPTIONS",
|
||||
] as const);
|
||||
|
||||
export type AuthAnonErrorCode =
|
||||
(typeof AUTH_ANON_CODES)[keyof typeof AUTH_ANON_CODES];
|
||||
|
||||
/**
|
||||
* Concrete error class for the anonymous-auth package. Extends
|
||||
* {@link SuaveplanError} so `instanceof AuthAnonError` narrows `code` to
|
||||
* {@link AuthAnonErrorCode}.
|
||||
*/
|
||||
export class AuthAnonError extends SuaveplanError<AuthAnonErrorCode> {
|
||||
public readonly authAnonCode: AuthAnonErrorCode;
|
||||
|
||||
public constructor(
|
||||
code: AuthAnonErrorCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.authAnonCode = code;
|
||||
}
|
||||
}
|
||||
11
packages/api-stack/api/api-auth-anonymous/src/index.ts
Normal file
11
packages/api-stack/api/api-auth-anonymous/src/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// Adapter
|
||||
export { ANONYMOUS_AUTH_ADAPTER } from "./adapter/adapter.js";
|
||||
// Error codes
|
||||
export {
|
||||
AUTH_ANON_CODES,
|
||||
AuthAnonError,
|
||||
type AuthAnonErrorCode,
|
||||
} from "./error-codes/error-codes.js";
|
||||
// Resolver
|
||||
export { createAnonymousResolver } from "./resolver/resolver.js";
|
||||
export type { AnonymousResolverOptions } from "./resolver/resolver.types.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,96 @@
|
||||
import { runAuthSessionConformance } from "@suaveplan/api-auth/testing";
|
||||
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import { AUTH_ANON_CODES, AuthAnonError } from "../error-codes/error-codes.js";
|
||||
import { createAnonymousResolver } from "./resolver.js";
|
||||
|
||||
const TARGET = "https://example.test/api";
|
||||
|
||||
describe("createAnonymousResolver", () => {
|
||||
it("resolveSession returns null for every input", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = createAnonymousResolver();
|
||||
expect(await r.resolve(new Request(TARGET))).toBeNull();
|
||||
expect(
|
||||
await r.resolve(
|
||||
new Request(TARGET, {
|
||||
headers: {
|
||||
authorization: "Bearer x.y.z",
|
||||
cookie: "sp_session=abc",
|
||||
},
|
||||
})
|
||||
)
|
||||
).toBeNull();
|
||||
await r.dispose();
|
||||
});
|
||||
|
||||
it("resolveSession does not throw on weird URLs", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = createAnonymousResolver();
|
||||
expect(
|
||||
await r.resolve(new Request("https://example.test/?q=%FF"))
|
||||
).toBeNull();
|
||||
await r.dispose();
|
||||
});
|
||||
|
||||
it("dispose is idempotent", { timeout: 5000 }, async () => {
|
||||
const r = createAnonymousResolver();
|
||||
await r.dispose();
|
||||
await expect(r.dispose()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts no-arg call", { timeout: 5000 }, () => {
|
||||
const r = createAnonymousResolver();
|
||||
expect(typeof r.resolve).toBe("function");
|
||||
expect(typeof r.dispose).toBe("function");
|
||||
});
|
||||
|
||||
it("accepts explicit empty options", { timeout: 5000 }, () => {
|
||||
const r = createAnonymousResolver({});
|
||||
expect(typeof r.resolve).toBe("function");
|
||||
});
|
||||
|
||||
it("rejects non-object options with AUTH_ANON.INVALID_OPTIONS", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
createAnonymousResolver("nope" as never);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(AuthAnonError);
|
||||
expect((err as AuthAnonError).code).toBe(
|
||||
AUTH_ANON_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown keys with AUTH_ANON.INVALID_OPTIONS", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
createAnonymousResolver({ unknown: true } as never);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as AuthAnonError).code).toBe(
|
||||
AUTH_ANON_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("resolve return-type narrows to Promise<null>", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = createAnonymousResolver();
|
||||
// Type-level assertion: anonymous never produces a principal.
|
||||
expectTypeOf(r.resolve).returns.resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
runAuthSessionConformance({
|
||||
label: "anonymous-direct",
|
||||
factory: () => createAnonymousResolver(),
|
||||
seedNull: () => new Request(TARGET),
|
||||
// No principal seed — anonymous never recognises a request.
|
||||
seedPrincipal: null,
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { SessionResolver } from "@suaveplan/api-auth";
|
||||
import z from "zod";
|
||||
import { AUTH_ANON_CODES, AuthAnonError } from "../error-codes/error-codes.js";
|
||||
import type { AnonymousResolverOptions } from "./resolver.types.js";
|
||||
|
||||
/**
|
||||
* Empty-object Zod schema. Today the resolver takes no flags; the strict
|
||||
* schema guarantees future option additions are call-site-visible and rejects
|
||||
* non-object inputs at the boundary (rule "Zod wired at boundary").
|
||||
*/
|
||||
const OptionsSchema = z.object({}).strict().optional();
|
||||
|
||||
/**
|
||||
* Factory for the trivial null-returning {@link SessionResolver}.
|
||||
*
|
||||
* The returned resolver always resolves `null` regardless of request shape —
|
||||
* no header inspection, no network I/O, no internal state. It is the
|
||||
* canonical tail-of-chain resolver for fully public APIs and the foundation
|
||||
* conformance seed for every other session-resolver in the stack.
|
||||
*
|
||||
* `dispose()` is a no-op (still idempotent) because the resolver holds no
|
||||
* resources.
|
||||
*
|
||||
* @throws {AuthAnonError} `AUTH_ANON.INVALID_OPTIONS` when `opts` is not a
|
||||
* plain object or contains unknown keys.
|
||||
*/
|
||||
export function createAnonymousResolver(
|
||||
opts?: AnonymousResolverOptions
|
||||
): SessionResolver {
|
||||
const parsed = OptionsSchema.safeParse(opts);
|
||||
if (!parsed.success) {
|
||||
throw new AuthAnonError(
|
||||
AUTH_ANON_CODES.INVALID_OPTIONS,
|
||||
"createAnonymousResolver: options failed schema validation",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
return {
|
||||
resolve: async (_req: Request): Promise<null> => null,
|
||||
dispose: async (): Promise<void> => undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Options bag for {@link createAnonymousResolver}.
|
||||
*
|
||||
* Reserved for future flags (e.g. `logAnonymousAccess: boolean`). Today the
|
||||
* shape is empty; passing any non-object value throws
|
||||
* `AUTH_ANON.INVALID_OPTIONS`. The strict empty-object schema is preserved on
|
||||
* purpose — it forces callers to update the call site (rather than silently
|
||||
* pass extra fields through) when a future flag is added.
|
||||
*/
|
||||
export interface AnonymousResolverOptions {
|
||||
readonly _reserved?: never;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* §16 conformance proof — api-auth-anonymous's resolver MUST pass
|
||||
* `runAuthSessionConformance` from `@suaveplan/api-auth/testing`,
|
||||
* in "null-only" mode (no `seedPrincipal`) since the anonymous tail
|
||||
* resolver never returns a recognised principal.
|
||||
*/
|
||||
import { runAuthSessionConformance } from "../../../api-auth/src/testing/conformance.js";
|
||||
import { createAnonymousResolver } from "../../src/resolver/resolver.js";
|
||||
|
||||
runAuthSessionConformance({
|
||||
label: "api-auth-anonymous",
|
||||
factory: () => createAnonymousResolver(),
|
||||
seedNull: () => new Request("https://example.test/protected"),
|
||||
// anonymous resolver never recognises a principal — null-only mode.
|
||||
seedPrincipal: null,
|
||||
});
|
||||
12
packages/api-stack/api/api-auth-anonymous/tsconfig.json
Normal file
12
packages/api-stack/api/api-auth-anonymous/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
12
packages/api-stack/api/api-auth-anonymous/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-auth-anonymous/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
30
packages/api-stack/api/api-auth-anonymous/vite.config.ts
Normal file
30
packages/api-stack/api/api-auth-anonymous/vite.config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/api-auth-anonymous",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [/^@suaveplan\//, /^node:/, "tslib", "zod"],
|
||||
},
|
||||
},
|
||||
});
|
||||
31
packages/api-stack/api/api-auth-anonymous/vitest.config.ts
Normal file
31
packages/api-stack/api/api-auth-anonymous/vitest.config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user