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,155 @@
# @suaveplan/api-auth-bearer-static — SessionResolver adapter for service-to-service static bearer tokens. Matches Authorization: Bearer <token> against a fixed token→Principal table using constant-time comparison from @suaveplan/crypto-utils; unknown tokens defer to the next chain entry. Supports an optional per-token mutual-TLS gate that rejects when the host did not surface request.tls.peerCertificate. Ships both a direct surface (createBearerStaticResolver) and a worker-proxy surface (createBearerStaticResolverWorkerProxy) over @suaveplan/api-worker-rpc; both pass runAuthSessionConformance from @suaveplan/api-auth/testing. OpenAPI security-scheme contribution (HTTP Bearer). Zod-validated options, AUTH_BEARER_STATIC.* error codes, OTel match counter partitioned by outcome.
Stage-4 service-to-service `SessionResolver` adapter for the
`@suaveplan/api` plugin system. Matches an inbound `Authorization:
Bearer <token>` header against a fixed `token → Principal` table using
constant-time comparison from `@suaveplan/crypto-utils`. Unknown tokens
return `null` so the host's `SessionResolverChain` falls through to the
next adapter (typically `api-auth-jwt` for user sessions).
Ships both the direct surface (`createBearerStaticResolver`) and the
worker-proxy surface (`createBearerStaticResolverWorkerProxy` +
`serveBearerStaticResolverOnPort`) over `@suaveplan/api-worker-rpc`; both
pass `runAuthSessionConformance` from `@suaveplan/api-auth/testing`.
<!-- begin:auto-docs -->
## Documentation tree
- [constant-time](./src/constant-time/token-match.md) — `findTokenIndex` performs a constant-time lookup of a presented bearer token against a fixed table, returning the match…
- [token-match](./src/constant-time/token-match.md) — `findTokenIndex` performs a constant-time lookup of a presented bearer token against a fixed table, returning the match…
- [error-codes](./src/error-codes/error-codes.md) — The error-codes module defines the stable `AUTH_BEARER_STATIC.*` namespace that every public failure path in this packa…
- [error-codes](./src/error-codes/error-codes.md) — The error-codes module defines the stable `AUTH_BEARER_STATIC.*` namespace that every public failure path in this packa…
- [factory](./src/factory/create-bearer-static-resolver.md) — `createBearerStaticResolver` is the direct-surface entry point of the package. It accepts validated `BearerStaticResolv…
- [create-bearer-static-resolver](./src/factory/create-bearer-static-resolver.md) — `createBearerStaticResolver` is the direct-surface entry point of the package. It accepts validated `BearerStaticResolv…
- [mtls](./src/mtls/mtls-gate.md) — The mTLS gate enforces an optional second factor on individual token table entries. When operators mark an entry with `…
- [mtls-gate](./src/mtls/mtls-gate.md) — The mTLS gate enforces an optional second factor on individual token table entries. When operators mark an entry with `…
- [options](./src/options/bearer-static-resolver-options.md) — The options module owns the public construction surface of every resolver in this package. It defines the `BearerStatic…
- [bearer-static-resolver-options](./src/options/bearer-static-resolver-options.md) — The options module owns the public construction surface of every resolver in this package. It defines the `BearerStatic…
- [proxy](./src/proxy/create-bearer-static-resolver-worker-proxy.md) — The worker-proxy surface is the second of the two Stage-4 surfaces this adapter ships (`§5.2a`). It lets the host insta…
- [create-bearer-static-resolver-worker-proxy](./src/proxy/create-bearer-static-resolver-worker-proxy.md) — The worker-proxy surface is the second of the two Stage-4 surfaces this adapter ships (`§5.2a`). It lets the host insta…
- [security-scheme](./src/security-scheme/security-scheme-contribution.md) — This module builds the OpenAPI 3.1 `components.securitySchemes` contribution the adapter publishes to the host's `AuthA…
- [security-scheme-contribution](./src/security-scheme/security-scheme-contribution.md) — This module builds the OpenAPI 3.1 `components.securitySchemes` contribution the adapter publishes to the host's `AuthA…
- [telemetry](./src/telemetry/instruments.md) — The telemetry module owns the single OpenTelemetry counter this package emits — `api.auth.bearer_static_match_total` —…
- [instruments](./src/telemetry/instruments.md) — The telemetry module owns the single OpenTelemetry counter this package emits — `api.auth.bearer_static_match_total` —…
<!-- end:auto-docs -->
## Install
```sh
bun add @suaveplan/api-auth-bearer-static
```
## Direct surface
```ts
import { createBearerStaticResolver } from "@suaveplan/api-auth-bearer-static";
import { SessionResolverChain } from "@suaveplan/api-auth";
const serviceAuth = createBearerStaticResolver({
tokens: [
{
token: process.env.CRON_TOKEN,
principal: {
id: "svc_cron",
scopes: ["jobs:run"],
claims: { kind: "service" },
},
},
{
token: process.env.ADMIN_TOKEN,
principal: {
id: "svc_admin",
scopes: ["admin:*"],
claims: { kind: "admin" },
},
mtlsRequired: true,
},
],
});
// Chained — service tokens first, JWT users fall through.
const chain = new SessionResolverChain([serviceAuth, jwtResolver]);
```
## Chained-resolver pattern with `api-auth-jwt`
The intended deployment topology pairs this adapter with
`@suaveplan/api-auth-jwt` for user sessions. Static-token callers match
first; everything else flows to JWT. The chain's `first-match-wins`
semantics means unknown tokens never throw — they simply move on.
```ts
import { SessionResolverChain } from "@suaveplan/api-auth";
import { createBearerStaticResolver } from "@suaveplan/api-auth-bearer-static";
import { createJwtSessionResolver } from "@suaveplan/api-auth-jwt"; // future sibling
const chain = new SessionResolverChain([
createBearerStaticResolver({
tokens: [/* service callers */],
}),
createJwtSessionResolver({
issuer: "https://idp.example",
audience: "suaveplan",
}),
]);
```
## Worker-proxy surface
Use the worker-proxy variant when the host process should not hold the
real token table — e.g. an isolated auth worker serving multiple host
workers. The host imports `createBearerStaticResolverWorkerProxy(port)`;
the worker imports `serveBearerStaticResolverOnPort(port, options)`. The
proxy transparently passes `runAuthSessionConformance`, so plugin code
treats it identically to the direct surface.
```ts
// host.ts
const proxy = createBearerStaticResolverWorkerProxy(port);
const chain = new SessionResolverChain([proxy, jwtResolver]);
```
```ts
// worker.ts
serveBearerStaticResolverOnPort(port, {
tokens: [/* real table — never crosses to the host */],
});
```
## Optional mutual-TLS gate
Token entries MAY carry `mtlsRequired: true`. When set, the resolver
requires the host listener to surface a verified peer certificate via
`request.tls.peerCertificate`; absent → returns `null` (outcome:
`mtls-required`). The adapter does NOT establish TLS — that is the host
listener's responsibility (Bun TLS, Node `https`, Cloudflare Worker TLS
terminator, etc.).
## Token rotation
This adapter is intentionally static — rotate tokens by reloading host
config. There is no DB-backed lookup, no JWKS, no refresh loop. If
you need rotation without restarts, use `api-auth-better-auth` or a
future `api-auth-token-table` adapter; this one is the simplest possible
thing that resolves a fixed table against constant-time match.
## Errors
The package exposes the `AUTH_BEARER_STATIC.*` code namespace via
`AUTH_BEARER_STATIC_CODES`. Only `INVALID_OPTIONS` is thrown
(synchronously, at factory construction); `NO_TOKEN`, `UNKNOWN_TOKEN`,
and `MTLS_REQUIRED` are surfaced via the OTel match counter's `outcome`
label.
## Telemetry
One counter — `api.auth.bearer_static_match_total{outcome=match|no-token|unknown-token|mtls-required}`
— increments exactly once per `resolve()` call.
## Conformance
Both surfaces pass `runAuthSessionConformance` from
`@suaveplan/api-auth`. Spec-conformance tests live alongside the source
under `src/factory/*.spec-conformance.test.ts` and
`src/proxy/*.spec-conformance.test.ts`.

View File

@@ -0,0 +1,104 @@
{
"name": "@suaveplan/api-auth-bearer-static",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Stage-4 SessionResolver adapter for service-to-service static bearer tokens. Matches Authorization: Bearer <token> against a fixed token→Principal table using constant-time comparison from @suaveplan/crypto-utils; unknown tokens defer to the next chain entry. Supports an optional per-token mutual-TLS gate that rejects when the host did not surface request.tls.peerCertificate. Ships both a direct surface (createBearerStaticResolver) and a worker-proxy surface (createBearerStaticResolverWorkerProxy) over @suaveplan/api-worker-rpc; both pass runAuthSessionConformance from @suaveplan/api-auth/testing. OpenAPI security-scheme contribution (HTTP Bearer). Zod-validated options, AUTH_BEARER_STATIC.* error codes, OTel match counter partitioned by outcome.",
"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/factory/create-bearer-static-resolver.d.ts",
"default": "./dist/factory/create-bearer-static-resolver.js"
}
},
"./proxy": {
"import": {
"types": "./dist/proxy/create-bearer-static-resolver-worker-proxy.d.ts",
"default": "./dist/proxy/create-bearer-static-resolver-worker-proxy.js"
}
},
"./options": {
"import": {
"types": "./dist/options/bearer-static-resolver-options.d.ts",
"default": "./dist/options/bearer-static-resolver-options.js"
}
},
"./security-scheme": {
"import": {
"types": "./dist/security-scheme/security-scheme-contribution.d.ts",
"default": "./dist/security-scheme/security-scheme-contribution.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./constant-time": {
"import": {
"types": "./dist/constant-time/token-match.d.ts",
"default": "./dist/constant-time/token-match.js"
}
},
"./mtls": {
"import": {
"types": "./dist/mtls/mtls-gate.d.ts",
"default": "./dist/mtls/mtls-gate.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": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/api-auth": "workspace:^",
"@suaveplan/api-types": "workspace:^",
"@suaveplan/api-worker-rpc": "workspace:^",
"@suaveplan/crypto-utils": "workspace:^",
"@suaveplan/error": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@opentelemetry/sdk-metrics": "1.30.0",
"@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"]
}

View File

@@ -0,0 +1 @@
export { findTokenIndex } from "./token-match.js";

View File

@@ -0,0 +1,82 @@
# Token match — `@suaveplan/api-auth-bearer-static`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`findTokenIndex` performs a constant-time lookup of a presented bearer
token against a fixed table, returning the matching entry's index or `-1`
if no entry matches. The function is the security boundary of the package:
every other code path in the resolver assumes the match is timing-safe so
an attacker cannot binary-search the token table by measuring response
latency.
The implementation walks every entry, calling `constantTimeEqual` from
`@suaveplan/crypto-utils` once per row. The match-tracking variable is
written only on the first match; subsequent matches are ignored. Crucially
the loop does not break on first match — exiting early would leak the
position of the matching token via wall-clock timing.
## Features
- Wraps `@suaveplan/crypto-utils` `constantTimeEqual` over UTF-8 byte
views.
- Iterates the full table for every call — no early exit, no short-circuit
on `presented === entry.token`.
- Returns the first matching index or `-1`.
- Handles `undefined` entries (defensive guard for sparse arrays).
## Usage: Basic
```ts
import { findTokenIndex } from "@suaveplan/api-auth-bearer-static/constant-time";
const table = [
{ token: "T1" },
{ token: "T2" },
];
const index = findTokenIndex("T2", table);
// index === 1
```
## Usage: Advanced
```ts
import { findTokenIndex } from "@suaveplan/api-auth-bearer-static/constant-time";
interface TokenEntry {
readonly token: string;
readonly principalId: string;
}
const table: ReadonlyArray<TokenEntry> = [
{ token: "service-cron", principalId: "p1" },
{ token: "service-admin", principalId: "p2" },
];
function lookup(presented: string): TokenEntry | null {
const idx = findTokenIndex(presented, table);
return idx === -1 ? null : (table[idx] ?? null);
}
```
## API Reference
- `findTokenIndex(presented: string, table: ReadonlyArray<{ token: string }>): number`
— returns the index of the first entry whose `token` matches
`presented`, or `-1` if no entry matches.
## Implementation Notes
`constantTimeEqual` from `@suaveplan/crypto-utils` walks every byte of the
two `Uint8Array` views and accumulates differences into an integer via
bitwise OR; the function returns whether the accumulator stayed zero. The
length-mismatch fast path is documented as not secret (token lengths are
not secret in this adapter's threat model — the table is operator-supplied
config). The `TextEncoder` round-trip ensures we compare bytes, not
surrogate-pair code units; otherwise an attacker could compare a multi-
byte presented token against a single-byte entry and learn from the
length-mismatch shortcut whether their candidate had the right length.

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { findTokenIndex } from "./token-match.js";
describe("findTokenIndex", () => {
it("returns the matching index", { timeout: 5000 }, () => {
const table = [{ token: "T1" }, { token: "T2" }, { token: "T3" }];
expect(findTokenIndex("T2", table)).toBe(1);
});
it("returns -1 on miss", { timeout: 5000 }, () => {
const table = [{ token: "T1" }, { token: "T2" }];
expect(findTokenIndex("nope", table)).toBe(-1);
});
it("returns first index when duplicates present", {
timeout: 5000,
}, () => {
const table = [{ token: "X" }, { token: "X" }];
expect(findTokenIndex("X", table)).toBe(0);
});
it("comparison runs to completion regardless of mismatch position", {
timeout: 5000,
}, () => {
// We cannot reliably measure wall-clock timing in unit tests on
// shared hardware. Instead, observe behaviour: if the loop short-
// circuited on first mismatched byte, the function would still
// return the correct index — but we can prove no short-circuit by
// confirming a guard sentinel placed after the loop body runs for
// every entry. We do that by counting iterations via a Proxy.
let reads = 0;
const table = new Proxy(
[{ token: "AAA" }, { token: "BBB" }, { token: "CCC" }],
{
get(target, prop, receiver) {
if (typeof prop === "string" && /^\d+$/.test(prop)) {
reads++;
}
return Reflect.get(target, prop, receiver);
},
}
);
const idx = findTokenIndex("BBB", table);
expect(idx).toBe(1);
// Three entries accessed even though entry[1] matched.
expect(reads).toBe(3);
});
it("handles empty table", { timeout: 5000 }, () => {
expect(findTokenIndex("any", [])).toBe(-1);
});
it("treats undefined entries as misses", { timeout: 5000 }, () => {
// Construct a sparse-like array: TypeScript discourages this but
// it is a defensive guard against caller misuse.
const table = [
{ token: "A" },
undefined as unknown as { token: string },
{ token: "B" },
];
expect(findTokenIndex("B", table)).toBe(2);
expect(findTokenIndex("A", table)).toBe(0);
});
it("compares by UTF-8 bytes, not code units", { timeout: 5000 }, () => {
// Multi-byte token; encoded form has 4 bytes.
const table = [{ token: "café" }];
expect(findTokenIndex("café", table)).toBe(0);
expect(findTokenIndex("cafe", table)).toBe(-1);
});
});

View File

@@ -0,0 +1,35 @@
import { constantTimeEqual } from "@suaveplan/crypto-utils/constant-time";
/**
* Constant-time match of a presented bearer token against a fixed table.
*
* Walks every entry, comparing via `constantTimeEqual` from
* `@suaveplan/crypto-utils`. The loop runs to completion regardless of
* which entry matches — short-circuiting on first match would leak the
* position of the matching token in the table via wall-clock timing,
* which an attacker can use to probe valid prefixes by binary-searching
* the table size.
*
* Token equality is compared by UTF-8 byte representation; tokens with
* different byte lengths can never match (their `Uint8Array` views differ
* in length and `constantTimeEqual` returns `false` immediately — the
* length itself is not a secret per the documented contract).
*/
export function findTokenIndex(
presented: string,
table: ReadonlyArray<{ readonly token: string }>
): number {
const presentedBytes = new TextEncoder().encode(presented);
let foundIndex = -1;
for (let i = 0; i < table.length; i++) {
const entry = table[i];
if (entry === undefined) continue;
const candidateBytes = new TextEncoder().encode(entry.token);
// Always run the comparison — never short-circuit.
const equal = constantTimeEqual(presentedBytes, candidateBytes);
if (equal && foundIndex === -1) {
foundIndex = i;
}
}
return foundIndex;
}

View File

@@ -0,0 +1,94 @@
# Error codes — `@suaveplan/api-auth-bearer-static`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The error-codes module defines the stable `AUTH_BEARER_STATIC.*` namespace
that every public failure path in this package surfaces. Codes are produced
by `defineCodes` from `@suaveplan/error` so the registry yields a frozen
record of fully-qualified strings (`AUTH_BEARER_STATIC.NO_TOKEN`,
`AUTH_BEARER_STATIC.UNKNOWN_TOKEN`, `AUTH_BEARER_STATIC.MTLS_REQUIRED`,
`AUTH_BEARER_STATIC.INVALID_OPTIONS`) along with a `BearerStaticAuthError`
subclass that lifts each code into a real `Error` instance.
This module is the contract a caller branches on. Catching a generic
`SuaveplanError` and reading `.code` is the supported failure-handling
pattern; the concrete subclass exists to make `instanceof` narrow the code
union for downstream code that needs the stronger typing.
## Features
- Frozen four-entry code registry keyed by reason name.
- `BearerStaticAuthError` extends `SuaveplanError<BearerStaticAuthErrorCode>`
— `instanceof` narrows `.code`.
- Stable namespace string (`AUTH_BEARER_STATIC`) safe to log and persist.
- Every code exercised by at least one production code path (registry-vs-
source conformance is asserted by the spec-conformance test).
## Usage: Basic
```ts
import {
AUTH_BEARER_STATIC_CODES,
BearerStaticAuthError,
} from "@suaveplan/api-auth-bearer-static/error-codes";
try {
// factory call that may throw on bad options
throw new BearerStaticAuthError(
AUTH_BEARER_STATIC_CODES.INVALID_OPTIONS,
"tokens table is empty"
);
} catch (e) {
if (e instanceof BearerStaticAuthError) {
// e.code narrows to BearerStaticAuthErrorCode
console.error(e.code, e.message);
}
}
```
## Usage: Advanced
```ts
import {
AUTH_BEARER_STATIC_CODES,
BearerStaticAuthError,
} from "@suaveplan/api-auth-bearer-static/error-codes";
function isInvalidOptions(err: unknown): err is BearerStaticAuthError {
return (
err instanceof BearerStaticAuthError &&
err.code === AUTH_BEARER_STATIC_CODES.INVALID_OPTIONS
);
}
// Treat invalid options as a fatal config error; bubble everything else.
try {
// build resolver
} catch (e) {
if (isInvalidOptions(e)) {
process.exit(78); // EX_CONFIG
}
throw e;
}
```
## API Reference
- `AUTH_BEARER_STATIC_CODES` — `Readonly<Record<Reason,
"AUTH_BEARER_STATIC.<Reason>">>`. Frozen at module load.
- `BearerStaticAuthErrorCode` — string-literal union of every value.
- `class BearerStaticAuthError extends SuaveplanError<BearerStaticAuthErrorCode>`
— constructor `(code, messageKey, options?)` matches the
`SuaveplanError` shape.
## Implementation Notes
The registry is constructed with `defineCodes("AUTH_BEARER_STATIC", [...])`.
`defineCodes` returns a `Readonly` record whose key set is the reason names
and whose values are the fully-qualified `"AUTH_BEARER_STATIC.<reason>"`
strings — referenced via `AUTH_BEARER_STATIC_CODES.NO_TOKEN` rather than the
raw string literal so a typo at the call site fails type-check.

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AUTH_BEARER_STATIC_CODES,
BearerStaticAuthError,
} from "./error-codes.js";
describe("AUTH_BEARER_STATIC_CODES", () => {
it("exposes the documented four codes with namespaced values", {
timeout: 5000,
}, () => {
expect(AUTH_BEARER_STATIC_CODES.NO_TOKEN).toBe(
"AUTH_BEARER_STATIC.NO_TOKEN"
);
expect(AUTH_BEARER_STATIC_CODES.UNKNOWN_TOKEN).toBe(
"AUTH_BEARER_STATIC.UNKNOWN_TOKEN"
);
expect(AUTH_BEARER_STATIC_CODES.MTLS_REQUIRED).toBe(
"AUTH_BEARER_STATIC.MTLS_REQUIRED"
);
expect(AUTH_BEARER_STATIC_CODES.INVALID_OPTIONS).toBe(
"AUTH_BEARER_STATIC.INVALID_OPTIONS"
);
});
it("registry is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(AUTH_BEARER_STATIC_CODES)).toBe(true);
});
});
describe("BearerStaticAuthError", () => {
it("instanceof narrows code and message survives", {
timeout: 5000,
}, () => {
const err = new BearerStaticAuthError(
AUTH_BEARER_STATIC_CODES.INVALID_OPTIONS,
"boom"
);
expect(err).toBeInstanceOf(BearerStaticAuthError);
expect(err.code).toBe(AUTH_BEARER_STATIC_CODES.INVALID_OPTIONS);
expect(err.bearerStaticCode).toBe(
AUTH_BEARER_STATIC_CODES.INVALID_OPTIONS
);
expect(err.message).toBe("boom");
expect(err.name).toBe("BearerStaticAuthError");
});
it("preserves cause", { timeout: 5000 }, () => {
const cause = new Error("upstream");
const err = new BearerStaticAuthError(
AUTH_BEARER_STATIC_CODES.INVALID_OPTIONS,
"wrapped",
{ cause }
);
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,52 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-auth-bearer-static`.
*
* Every public failure path raises a {@link BearerStaticAuthError} carrying
* one of these namespaced `AUTH_BEARER_STATIC.*` codes. Consumers branch on
* the string value (e.g. `err.code === AUTH_BEARER_STATIC_CODES.NO_TOKEN`).
*
* The four codes cover:
* - {@link AUTH_BEARER_STATIC_CODES.NO_TOKEN} — request carried no
* Authorization header (or it lacked the configured scheme).
* - {@link AUTH_BEARER_STATIC_CODES.UNKNOWN_TOKEN} — header presented a
* token absent from the configured table.
* - {@link AUTH_BEARER_STATIC_CODES.MTLS_REQUIRED} — token entry required
* a mutual-TLS peer certificate but the host did not surface one.
* - {@link AUTH_BEARER_STATIC_CODES.INVALID_OPTIONS} — Zod validation of
* `BearerStaticResolverOptions` failed at factory construction.
*
* `NO_TOKEN`, `UNKNOWN_TOKEN`, and `MTLS_REQUIRED` are surfaced via the
* outcome label on the OTel match counter (and would-be log lines); they
* are not thrown synchronously because resolvers return `null` to defer to
* the next chain entry. `INVALID_OPTIONS` is the sole thrown code.
*/
export const AUTH_BEARER_STATIC_CODES = defineCodes("AUTH_BEARER_STATIC", [
"NO_TOKEN",
"UNKNOWN_TOKEN",
"MTLS_REQUIRED",
"INVALID_OPTIONS",
] as const);
export type BearerStaticAuthErrorCode =
(typeof AUTH_BEARER_STATIC_CODES)[keyof typeof AUTH_BEARER_STATIC_CODES];
/**
* Concrete error class. Extends {@link SuaveplanError} so `instanceof`
* narrows `code` to {@link BearerStaticAuthErrorCode}.
*/
export class BearerStaticAuthError extends SuaveplanError<BearerStaticAuthErrorCode> {
public readonly bearerStaticCode: BearerStaticAuthErrorCode;
public constructor(
code: BearerStaticAuthErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.name = "BearerStaticAuthError";
this.bearerStaticCode = code;
}
}

View File

@@ -0,0 +1,5 @@
export {
AUTH_BEARER_STATIC_CODES,
BearerStaticAuthError,
type BearerStaticAuthErrorCode,
} from "./error-codes.js";

View File

@@ -0,0 +1,106 @@
# Direct factory — `@suaveplan/api-auth-bearer-static`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createBearerStaticResolver` is the direct-surface entry point of the
package. It accepts validated `BearerStaticResolverOptions`, builds a
`SessionResolver` over the fixed token table, and returns a resolver
implementation suitable for placing inside a `SessionResolverChain` from
`@suaveplan/api-auth`. The resolver matches the documented chain
semantics: an unknown token, missing header, or tripped mTLS gate all
return `null` so the chain advances to the next entry.
The resolver is stateless beyond the disposed flag — there is no DB
connection, no JWKS cache, no refresh loop. Disposal flips the flag and
subsequent calls return `null` rather than throwing.
## Features
- Stateless per-request resolution; safe under concurrent fanout.
- Constant-time token match via `findTokenIndex`.
- Optional per-entry mTLS gate via `checkMtlsGate`.
- Configurable `headerName` and prefix (`Bearer` by default).
- Case-insensitive scheme match per RFC 7235.
- OTel counter emission for every of the four outcomes.
## Usage: Basic
```ts
import { createBearerStaticResolver } from "@suaveplan/api-auth-bearer-static";
const resolver = createBearerStaticResolver({
tokens: [
{
token: "service-cron-2026",
principal: {
id: "svc_cron",
scopes: ["jobs:run"],
claims: { kind: "service" },
},
},
],
});
const req = new Request("https://api/x", {
headers: { Authorization: "Bearer service-cron-2026" },
});
const principal = await resolver.resolve(req);
// principal.id === "svc_cron"
```
## Usage: Advanced
```ts
import { createBearerStaticResolver } from "@suaveplan/api-auth-bearer-static";
import { SessionResolverChain } from "@suaveplan/api-auth";
import { createJwtResolver } from "@suaveplan/api-auth-jwt"; // hypothetical sibling
// Chained — service tokens checked first, JWT users fall through after.
const chain = new SessionResolverChain([
createBearerStaticResolver({
tokens: [
{
token: "admin-2026",
principal: {
id: "svc_admin",
scopes: ["admin:*"],
claims: {},
},
mtlsRequired: true,
},
],
headerName: "X-Service-Auth",
prefix: "Token",
}),
createJwtResolver({ issuer: "https://idp/..." }),
]);
const result = await chain.resolve(request);
```
## API Reference
- `createBearerStaticResolver(options: BearerStaticResolverOptions): SessionResolver`
Returned resolver:
- `resolve(request: Request): Promise<Principal | null>`
- `dispose(): Promise<void>` — idempotent.
## Implementation Notes
`extractToken` accepts the header value, trims, splits on the first space,
and lower-cases the scheme for the prefix comparison. Empty credentials
after the prefix are rejected. The implementation reads the configured
header by exact name (the Web `Headers` API normalises case on lookup, so
`Authorization` and `authorization` both work). When the table contains
the same token twice with conflicting principals, `findTokenIndex` returns
the first index — operator misconfiguration the schema cannot prevent.
TypeScript cannot narrow `table[idx]` to non-undefined even though
`findTokenIndex` only ever returns `-1` or a valid in-range index; a type
assertion encodes that contract at the call site without adding a runtime
guard the test would have to exercise.

View File

@@ -0,0 +1,32 @@
import { runAuthSessionConformance } from "@suaveplan/api-auth";
import type { Principal } from "@suaveplan/api-types/principal";
import { createBearerStaticResolver } from "./create-bearer-static-resolver.js";
const expectedPrincipal = {
id: "svc_cron",
scopes: ["jobs:run"],
claims: { kind: "service" },
} as unknown as Principal;
runAuthSessionConformance({
label: "bearer-static-direct",
factory: () =>
createBearerStaticResolver({
tokens: [
{
token: "T-CONFORMANCE",
principal: expectedPrincipal,
},
],
}),
seedNull: () =>
new Request("https://api/x", {
headers: { Authorization: "Bearer NOT_PRESENT" },
}),
seedPrincipal: () => ({
request: new Request("https://api/x", {
headers: { Authorization: "Bearer T-CONFORMANCE" },
}),
expected: expectedPrincipal,
}),
});

View File

@@ -0,0 +1,254 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
import {
AUTH_BEARER_STATIC_CODES,
BearerStaticAuthError,
} from "../error-codes/error-codes.js";
import { resetBearerStaticInstruments } from "../telemetry/instruments.js";
import { createBearerStaticResolver } from "./create-bearer-static-resolver.js";
const principalA = {
id: "svc_a",
scopes: ["read"],
claims: { kind: "service" },
};
const principalB = {
id: "svc_b",
scopes: ["admin"],
claims: { kind: "admin" },
};
function makeResolver(extra?: Record<string, unknown>) {
return createBearerStaticResolver({
tokens: [
{ token: "T1", principal: principalA },
{ token: "T2", principal: principalB, mtlsRequired: true },
],
...extra,
});
}
function findCounter(
name: string,
metrics: Awaited<ReturnType<typeof flushTestMetrics>>
): { value: number; attributes: Record<string, unknown> }[] {
const out: { value: number; attributes: Record<string, unknown> }[] = [];
for (const rm of metrics) {
for (const sm of rm.scopeMetrics) {
for (const m of sm.metrics) {
if (m.descriptor.name !== name) continue;
for (const dp of m.dataPoints) {
out.push({
value: Number(dp.value),
attributes: (dp.attributes ?? {}) as Record<
string,
unknown
>,
});
}
}
}
}
return out;
}
function attachTls(request: Request, peerCertificate: unknown): Request {
Object.defineProperty(request, "tls", {
value: { peerCertificate },
enumerable: true,
configurable: true,
});
return request;
}
describe("createBearerStaticResolver", () => {
beforeEach(() => {
resetBearerStaticInstruments();
});
it("known token returns its Principal", { timeout: 5000 }, async () => {
const resolver = makeResolver();
const req = new Request("https://api/x", {
headers: { Authorization: "Bearer T1" },
});
const result = await resolver.resolve(req);
expect(result).not.toBeNull();
expect(result?.id).toBe("svc_a");
await resolver.dispose();
});
it("unknown token returns null and increments failure counter", {
timeout: 5000,
}, async () => {
await flushTestMetrics();
const resolver = makeResolver();
const req = new Request("https://api/x", {
headers: { Authorization: "Bearer NOT_IN_TABLE" },
});
const result = await resolver.resolve(req);
expect(result).toBeNull();
const collected = await flushTestMetrics();
const points = findCounter(
"api.auth.bearer_static_match_total",
collected
);
const match = points.find(
(p) => p.attributes.outcome === "unknown-token"
);
expect(match?.value).toBeGreaterThanOrEqual(1);
await resolver.dispose();
});
it("missing Authorization header returns null + no-token outcome", {
timeout: 5000,
}, async () => {
await flushTestMetrics();
const resolver = makeResolver();
const result = await resolver.resolve(new Request("https://api/x"));
expect(result).toBeNull();
const collected = await flushTestMetrics();
const points = findCounter(
"api.auth.bearer_static_match_total",
collected
);
const match = points.find((p) => p.attributes.outcome === "no-token");
expect(match?.value).toBeGreaterThanOrEqual(1);
await resolver.dispose();
});
it("empty Authorization header returns null", {
timeout: 5000,
}, async () => {
const resolver = makeResolver();
const req = new Request("https://api/x", {
headers: { Authorization: "" },
});
expect(await resolver.resolve(req)).toBeNull();
});
it("whitespace-only Authorization header returns null", {
timeout: 5000,
}, async () => {
const resolver = makeResolver();
const req = new Request("https://api/x", {
headers: { Authorization: " " },
});
expect(await resolver.resolve(req)).toBeNull();
});
it("Authorization without space separator returns null", {
timeout: 5000,
}, async () => {
const resolver = makeResolver();
const req = new Request("https://api/x", {
headers: { Authorization: "BearerT1" },
});
expect(await resolver.resolve(req)).toBeNull();
});
it("wrong scheme returns null", { timeout: 5000 }, async () => {
const resolver = makeResolver();
const req = new Request("https://api/x", {
headers: { Authorization: "Basic dXNlcjpwYXNz" },
});
expect(await resolver.resolve(req)).toBeNull();
});
it("empty credential after scheme returns null", {
timeout: 5000,
}, async () => {
const resolver = makeResolver();
const req = new Request("https://api/x", {
headers: { Authorization: "Bearer " },
});
expect(await resolver.resolve(req)).toBeNull();
});
it("scheme match is case-insensitive", { timeout: 5000 }, async () => {
const resolver = makeResolver();
const req = new Request("https://api/x", {
headers: { Authorization: "bEaReR T1" },
});
expect((await resolver.resolve(req))?.id).toBe("svc_a");
});
it("mtlsRequired token rejected without peer cert", {
timeout: 5000,
}, async () => {
await flushTestMetrics();
const resolver = makeResolver();
const req = new Request("https://api/x", {
headers: { Authorization: "Bearer T2" },
});
expect(await resolver.resolve(req)).toBeNull();
const collected = await flushTestMetrics();
const points = findCounter(
"api.auth.bearer_static_match_total",
collected
);
const match = points.find(
(p) => p.attributes.outcome === "mtls-required"
);
expect(match?.value).toBeGreaterThanOrEqual(1);
});
it("mtlsRequired token accepted with peer cert", {
timeout: 5000,
}, async () => {
await flushTestMetrics();
const resolver = makeResolver();
const req = attachTls(
new Request("https://api/x", {
headers: { Authorization: "Bearer T2" },
}),
{ subject: "CN=svc_b" }
);
const result = await resolver.resolve(req);
expect(result?.id).toBe("svc_b");
const collected = await flushTestMetrics();
const points = findCounter(
"api.auth.bearer_static_match_total",
collected
);
const match = points.find((p) => p.attributes.outcome === "match");
expect(match?.value).toBeGreaterThanOrEqual(1);
});
it("custom headerName + prefix", { timeout: 5000 }, async () => {
const resolver = makeResolver({
headerName: "X-Service-Auth",
prefix: "Token",
});
const req = new Request("https://api/x", {
headers: { "X-Service-Auth": "Token T1" },
});
expect((await resolver.resolve(req))?.id).toBe("svc_a");
});
it("dispose is idempotent and silences subsequent resolves", {
timeout: 5000,
}, async () => {
const resolver = makeResolver();
await resolver.dispose();
await resolver.dispose();
const req = new Request("https://api/x", {
headers: { Authorization: "Bearer T1" },
});
expect(await resolver.resolve(req)).toBeNull();
});
it("throws INVALID_OPTIONS when called with invalid options", {
timeout: 5000,
}, () => {
try {
createBearerStaticResolver({ tokens: [] });
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(BearerStaticAuthError);
expect((err as BearerStaticAuthError).code).toBe(
AUTH_BEARER_STATIC_CODES.INVALID_OPTIONS
);
}
});
});

View File

@@ -0,0 +1,98 @@
import type { SessionResolver } from "@suaveplan/api-auth";
import type { Principal } from "@suaveplan/api-types/principal";
import { findTokenIndex } from "../constant-time/token-match.js";
import { checkMtlsGate } from "../mtls/mtls-gate.js";
import {
type BearerStaticResolverOptions,
parseBearerStaticResolverOptions,
} from "../options/bearer-static-resolver-options.js";
import { recordMatchOutcome } from "../telemetry/instruments.js";
const DEFAULT_HEADER = "Authorization";
const DEFAULT_PREFIX = "Bearer";
/**
* Parse the configured header and strip the configured prefix. Returns the
* raw token string, or `null` when the header is missing / the prefix
* doesn't match.
*
* The prefix match is case-insensitive (per RFC 7235, scheme tokens are
* ASCII case-insensitive); the separating space is required.
*/
function extractToken(
request: Request,
headerName: string,
prefix: string
): string | null {
const raw = request.headers.get(headerName);
if (raw === null) return null;
const trimmed = raw.trim();
if (trimmed.length === 0) return null;
// Find the first whitespace separator.
const spaceIdx = trimmed.indexOf(" ");
if (spaceIdx === -1) return null;
const scheme = trimmed.slice(0, spaceIdx);
if (scheme.toLowerCase() !== prefix.toLowerCase()) return null;
// Inner trim collapses runs of whitespace between scheme and the
// credential (e.g. "Bearer X" → credential "X"). The outer trim
// already removed any trailing whitespace from `trimmed`, so an empty
// result is unreachable via the standard `Headers` API; downstream
// `findTokenIndex` reports unknown-token if it somehow appears.
return trimmed.slice(spaceIdx + 1).replace(/^\s+/, "");
}
/**
* Build a {@link SessionResolver} that matches static bearer tokens
* against a fixed table.
*
* Behaviour:
* - No `Authorization` header (or wrong scheme) → returns `null`, emits
* `no-token` outcome.
* - Header present, token absent from table → returns `null`, emits
* `unknown-token` outcome.
* - Token in table but entry's `mtlsRequired` flag set AND no peer
* certificate attached to the request → returns `null`, emits
* `mtls-required` outcome.
* - Token in table, mTLS gate satisfied → returns the entry's
* `Principal`, emits `match` outcome.
*
* `dispose()` is idempotent and has no resources to release (the table is
* held by reference; nothing to close).
*/
export function createBearerStaticResolver(
rawOptions: BearerStaticResolverOptions | unknown
): SessionResolver {
const opts = parseBearerStaticResolverOptions(rawOptions);
const headerName = opts.headerName ?? DEFAULT_HEADER;
const prefix = opts.prefix ?? DEFAULT_PREFIX;
const table = opts.tokens;
let disposed = false;
return {
async resolve(req: Request): Promise<Principal | null> {
if (disposed) return null;
const presented = extractToken(req, headerName, prefix);
if (presented === null) {
recordMatchOutcome("no-token");
return null;
}
const idx = findTokenIndex(presented, table);
if (idx === -1) {
recordMatchOutcome("unknown-token");
return null;
}
// `findTokenIndex` only returns -1 or a valid in-range index,
// so non-null assertion here matches its contract.
const entry = table[idx] as (typeof table)[number];
if (!checkMtlsGate(req, entry.mtlsRequired ?? false)) {
recordMatchOutcome("mtls-required");
return null;
}
recordMatchOutcome("match");
return entry.principal;
},
async dispose(): Promise<void> {
disposed = true;
},
};
}

View File

@@ -0,0 +1 @@
export { createBearerStaticResolver } from "./create-bearer-static-resolver.js";

View File

@@ -0,0 +1,42 @@
// Error codes
// Constant-time match
export { findTokenIndex } from "./constant-time/token-match.js";
export {
AUTH_BEARER_STATIC_CODES,
BearerStaticAuthError,
type BearerStaticAuthErrorCode,
} from "./error-codes/error-codes.js";
// Direct factory
export { createBearerStaticResolver } from "./factory/create-bearer-static-resolver.js";
// mTLS
export {
checkMtlsGate,
extractPeerCertificate,
type RequestTlsInfo,
} from "./mtls/mtls-gate.js";
// Options
export {
type BearerStaticResolverOptions,
type BearerStaticTokenEntry,
bearerStaticResolverOptionsSchema,
parseBearerStaticResolverOptions,
} from "./options/bearer-static-resolver-options.js";
// Worker-proxy factories
export {
createBearerStaticResolverWorkerProxy,
serveBearerStaticResolverOnPort,
} from "./proxy/create-bearer-static-resolver-worker-proxy.js";
// Security scheme
export {
type SecuritySchemeContributionOptions,
securitySchemeContribution,
} from "./security-scheme/security-scheme-contribution.js";
// Telemetry
export {
type BearerStaticInstruments,
type BearerStaticMatchOutcome,
getBearerStaticInstruments,
recordMatchOutcome,
resetBearerStaticInstruments,
} from "./telemetry/instruments.js";

View File

@@ -0,0 +1,5 @@
export {
checkMtlsGate,
extractPeerCertificate,
type RequestTlsInfo,
} from "./mtls-gate.js";

View File

@@ -0,0 +1,79 @@
# mTLS gate — `@suaveplan/api-auth-bearer-static`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The mTLS gate enforces an optional second factor on individual token table
entries. When operators mark an entry with `mtlsRequired: true`, the
resolver MUST refuse to surface the entry's Principal unless the host
listener attached a verified peer certificate to the inbound request via
`request.tls.peerCertificate`. This guards the highest-privilege service
tokens (e.g. internal admin API) against credential leakage — the bearer
token alone is necessary but no longer sufficient.
The adapter does NOT establish TLS itself. The host listener (Bun's
TLS-enabled server, Node's `https`, a Cloudflare-Worker TLS terminator
ahead of the worker, etc.) is responsible for completing the TLS handshake
and surfacing the verified subject as `request.tls.peerCertificate`. This
module just inspects what was attached.
## Features
- Pure function — no I/O, no state.
- Defensive — accepts any `Request`-shaped input even when the `tls` field
was not attached (returns `undefined`).
- Treats `peerCertificate: null` identically to absent.
- `mtlsRequired === false` short-circuits without inspecting the request.
## Usage: Basic
```ts
import { checkMtlsGate } from "@suaveplan/api-auth-bearer-static/mtls";
const request = new Request("https://example/test");
const allowed = checkMtlsGate(request, true);
// allowed === false — no peerCertificate attached
```
## Usage: Advanced
```ts
import {
checkMtlsGate,
extractPeerCertificate,
} from "@suaveplan/api-auth-bearer-static/mtls";
// Host listener pattern — wrap the inbound request before handing off to
// the auth chain.
const withTls = Object.assign(request, {
tls: { peerCertificate: parsedX509Subject },
});
const cert = extractPeerCertificate(withTls);
// cert === parsedX509Subject
const allowed = checkMtlsGate(withTls, true);
// allowed === true
```
## API Reference
- `interface RequestTlsInfo { peerCertificate?: unknown }`
- `extractPeerCertificate(request: Request): unknown | undefined`
- `checkMtlsGate(request: Request, mtlsRequired: boolean): boolean`
## Implementation Notes
The `Request` Web type has no standardised `tls` field. Both Node's
undici-based `Request` and Bun's polyfill ignore unknown properties on
construction, so the host pattern is to attach `tls` as a plain property
after the Request is built (or to wrap it in a Proxy / subclass). The
extractor reads through that property with a single defensive type cast
and returns `undefined` for any shape that does not satisfy the
`{ peerCertificate: <truthy> }` invariant. The opaque certificate itself
is not parsed here — that is the host's job, and a future
`api-auth-mtls` adapter (out of scope for Stage 4) will own subject
extraction.

View File

@@ -0,0 +1,96 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { checkMtlsGate, extractPeerCertificate } from "./mtls-gate.js";
function makeRequest(): Request {
return new Request("https://example.test/x");
}
function attachTls<T extends object | null | undefined>(
request: Request,
tls: T
): Request {
Object.defineProperty(request, "tls", {
value: tls,
enumerable: true,
configurable: true,
});
return request;
}
describe("extractPeerCertificate", () => {
it("returns undefined when no tls attached", { timeout: 5000 }, () => {
const req = makeRequest();
expect(extractPeerCertificate(req)).toBeUndefined();
});
it("returns undefined when tls is null", { timeout: 5000 }, () => {
const req = attachTls(makeRequest(), null);
expect(extractPeerCertificate(req)).toBeUndefined();
});
it("returns undefined when tls is not an object", {
timeout: 5000,
}, () => {
const req = attachTls(
makeRequest(),
"not-an-object" as unknown as object
);
expect(extractPeerCertificate(req)).toBeUndefined();
});
it("returns undefined when tls has no peerCertificate key", {
timeout: 5000,
}, () => {
const req = attachTls(makeRequest(), {});
expect(extractPeerCertificate(req)).toBeUndefined();
});
it("returns undefined when peerCertificate is null", {
timeout: 5000,
}, () => {
const req = attachTls(makeRequest(), { peerCertificate: null });
expect(extractPeerCertificate(req)).toBeUndefined();
});
it("returns undefined when peerCertificate is undefined", {
timeout: 5000,
}, () => {
const req = attachTls(makeRequest(), { peerCertificate: undefined });
expect(extractPeerCertificate(req)).toBeUndefined();
});
it("returns the attached certificate when present", {
timeout: 5000,
}, () => {
const cert = { subject: "CN=svc" };
const req = attachTls(makeRequest(), { peerCertificate: cert });
expect(extractPeerCertificate(req)).toBe(cert);
});
});
describe("checkMtlsGate", () => {
it("rejects mtls-required token without peer certificate", {
timeout: 5000,
}, () => {
expect(checkMtlsGate(makeRequest(), true)).toBe(false);
});
it("accepts mtls-required token with peer certificate", {
timeout: 5000,
}, () => {
const req = attachTls(makeRequest(), {
peerCertificate: { subject: "CN=svc" },
});
expect(checkMtlsGate(req, true)).toBe(true);
});
it("passes when mtls not required, regardless of cert presence", {
timeout: 5000,
}, () => {
expect(checkMtlsGate(makeRequest(), false)).toBe(true);
const withCert = attachTls(makeRequest(), {
peerCertificate: { subject: "x" },
});
expect(checkMtlsGate(withCert, false)).toBe(true);
});
});

View File

@@ -0,0 +1,46 @@
/**
* Surface a host listener may attach to an inbound `Request` indicating a
* mutual-TLS peer certificate has been verified.
*
* `peerCertificate` is opaque to this adapter — the host owns parsing /
* validation; we only check presence. The shape mirrors the documented
* integration point in design §2.3: `request.tls?.peerCertificate`.
*/
export interface RequestTlsInfo {
readonly peerCertificate?: unknown;
}
/**
* Type-narrowing helper that pulls `tls.peerCertificate` from a `Request`
* if the host attached it. Returns `undefined` when absent or malformed.
*
* Inspects `(request as any).tls` defensively — the `Request` interface
* does not standardise this field, so the host's listener augments it via
* a wrapping `Request` subclass or by attaching to a parallel context
* object that ends up on the request. Either form is supported.
*/
export function extractPeerCertificate(request: Request): unknown | undefined {
const tls = (request as unknown as { readonly tls?: RequestTlsInfo }).tls;
if (tls === undefined || tls === null) return undefined;
if (typeof tls !== "object") return undefined;
if (!("peerCertificate" in tls)) return undefined;
const cert = tls.peerCertificate;
if (cert === undefined || cert === null) return undefined;
return cert;
}
/**
* Enforce the per-token `mtlsRequired` flag.
*
* Returns `true` when the gate passes (token did not require mTLS, OR the
* host surfaced a peer certificate), `false` when the gate trips (mTLS
* required but no peer cert).
*/
export function checkMtlsGate(
request: Request,
mtlsRequired: boolean
): boolean {
if (!mtlsRequired) return true;
const cert = extractPeerCertificate(request);
return cert !== undefined;
}

View File

@@ -0,0 +1,96 @@
# Options — `@suaveplan/api-auth-bearer-static`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The options module owns the public construction surface of every resolver
in this package. It defines the `BearerStaticResolverOptions` interface
(plus its row type `BearerStaticTokenEntry`), the Zod schema that mirrors
the interface, and a thin parser that turns operator-supplied input into a
validated value or throws `AUTH_BEARER_STATIC.INVALID_OPTIONS`.
Validation runs once at factory construction. Hot-path resolve calls never
re-validate — by then the table is structurally trusted. Token rotation is
intentionally out of scope: operators rotate by reloading host config,
which re-runs the factory and re-runs validation.
## Features
- `tokens` is required and non-empty (`.min(1)`).
- Every entry validates token (non-empty string), `principal` shape (id +
scopes + claims, optional tenantId), and optional `mtlsRequired` flag.
- `headerName` and `prefix` are optional with documented defaults
(`Authorization` / `Bearer`).
- Failure surfaces a typed `BearerStaticAuthError` carrying the original
`ZodError` as `cause` — full diagnostic trail is preserved.
## Usage: Basic
```ts
import { parseBearerStaticResolverOptions } from "@suaveplan/api-auth-bearer-static/options";
const opts = parseBearerStaticResolverOptions({
tokens: [
{
token: "service-cron-2026",
principal: {
id: "svc_cron",
scopes: ["jobs:run"],
claims: { kind: "service" },
},
},
],
});
```
## Usage: Advanced
```ts
import {
parseBearerStaticResolverOptions,
bearerStaticResolverOptionsSchema,
} from "@suaveplan/api-auth-bearer-static/options";
import { AUTH_BEARER_STATIC_CODES } from "@suaveplan/api-auth-bearer-static/error-codes";
// Validate without constructing — useful for config UIs that show errors
// inline before committing changes.
const result = bearerStaticResolverOptionsSchema.safeParse(userInput);
if (!result.success) {
renderInlineErrors(result.error);
}
// Or rely on the throwing parser when integrated into a startup pipeline:
try {
const opts = parseBearerStaticResolverOptions(userInput);
return opts;
} catch (e) {
if (
e &&
typeof e === "object" &&
"code" in e &&
e.code === AUTH_BEARER_STATIC_CODES.INVALID_OPTIONS
) {
crashWithConfigExitCode();
}
throw e;
}
```
## API Reference
- `interface BearerStaticTokenEntry { token: string; principal: Principal; mtlsRequired?: boolean }`
- `interface BearerStaticResolverOptions { tokens: ReadonlyArray<BearerStaticTokenEntry>; headerName?: string; prefix?: string }`
- `bearerStaticResolverOptionsSchema: z.ZodType<BearerStaticResolverOptions>`
- `parseBearerStaticResolverOptions(raw: unknown): BearerStaticResolverOptions`
## Implementation Notes
The schema's `principal` block deliberately accepts strings rather than
`PrincipalId` / `Scope` brands. Brands are erased at runtime and the host
trusts operator-supplied config to be well-formed; rejecting strings that
look fine at runtime would be developer-hostile noise. Downstream code
treats the returned shape as a `Principal` because the structural fields
match.

View File

@@ -0,0 +1,144 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AUTH_BEARER_STATIC_CODES,
BearerStaticAuthError,
} from "../error-codes/error-codes.js";
import {
bearerStaticResolverOptionsSchema,
parseBearerStaticResolverOptions,
} from "./bearer-static-resolver-options.js";
const validInput = {
tokens: [
{
token: "T1",
principal: {
id: "u1",
scopes: ["read"],
claims: { sub: "u1" },
},
},
],
};
describe("parseBearerStaticResolverOptions", () => {
it("accepts valid input", { timeout: 5000 }, () => {
const out = parseBearerStaticResolverOptions(validInput);
expect(out.tokens.length).toBe(1);
expect(out.tokens[0]?.token).toBe("T1");
expect(out.tokens[0]?.principal.id).toBe("u1");
});
it("accepts optional headerName + prefix + mtlsRequired", {
timeout: 5000,
}, () => {
const out = parseBearerStaticResolverOptions({
tokens: [
{
token: "T1",
principal: {
id: "u1",
scopes: [],
claims: {},
},
mtlsRequired: true,
},
],
headerName: "X-Service-Auth",
prefix: "Token",
});
expect(out.headerName).toBe("X-Service-Auth");
expect(out.prefix).toBe("Token");
expect(out.tokens[0]?.mtlsRequired).toBe(true);
});
it("throws INVALID_OPTIONS on empty tokens array", {
timeout: 5000,
}, () => {
try {
parseBearerStaticResolverOptions({ tokens: [] });
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(BearerStaticAuthError);
const e = err as BearerStaticAuthError;
expect(e.code).toBe(AUTH_BEARER_STATIC_CODES.INVALID_OPTIONS);
expect(e.cause).toBeDefined();
}
});
it("throws INVALID_OPTIONS on missing tokens key", {
timeout: 5000,
}, () => {
expect(() => parseBearerStaticResolverOptions({})).toThrow(
BearerStaticAuthError
);
});
it("throws INVALID_OPTIONS on non-string token", {
timeout: 5000,
}, () => {
expect(() =>
parseBearerStaticResolverOptions({
tokens: [
{
token: 42,
principal: { id: "u1", scopes: [], claims: {} },
},
],
})
).toThrow(BearerStaticAuthError);
});
it("throws INVALID_OPTIONS on empty token string", {
timeout: 5000,
}, () => {
expect(() =>
parseBearerStaticResolverOptions({
tokens: [
{
token: "",
principal: { id: "u1", scopes: [], claims: {} },
},
],
})
).toThrow(BearerStaticAuthError);
});
it("rejects missing principal.id", { timeout: 5000 }, () => {
expect(() =>
parseBearerStaticResolverOptions({
tokens: [
{
token: "T1",
principal: { scopes: [], claims: {} },
},
],
})
).toThrow(BearerStaticAuthError);
});
it("rejects empty headerName", { timeout: 5000 }, () => {
expect(() =>
parseBearerStaticResolverOptions({
...validInput,
headerName: "",
})
).toThrow(BearerStaticAuthError);
});
it("rejects empty prefix", { timeout: 5000 }, () => {
expect(() =>
parseBearerStaticResolverOptions({
...validInput,
prefix: "",
})
).toThrow(BearerStaticAuthError);
});
it("schema can be used directly via safeParse", {
timeout: 5000,
}, () => {
const result = bearerStaticResolverOptionsSchema.safeParse(validInput);
expect(result.success).toBe(true);
});
});

View File

@@ -0,0 +1,75 @@
import type { Principal } from "@suaveplan/api-types/principal";
import z from "zod";
import {
AUTH_BEARER_STATIC_CODES,
BearerStaticAuthError,
} from "../error-codes/error-codes.js";
/**
* One row in the static token table.
*
* `token` is the literal bearer credential (caller-supplied, opaque).
* `principal` is the {@link Principal} the host attaches to
* `RequestContext` when the token matches.
* `mtlsRequired` (optional) gates this entry behind a verified peer
* certificate surfaced by the host listener.
*/
export interface BearerStaticTokenEntry {
readonly token: string;
readonly principal: Principal;
readonly mtlsRequired?: boolean;
}
/**
* Construction-time options for `createBearerStaticResolver` and
* `createBearerStaticResolverWorkerProxy`.
*
* `tokens` is required and MUST be non-empty. `headerName` and `prefix`
* default to `Authorization` and `Bearer` respectively.
*/
export interface BearerStaticResolverOptions {
readonly tokens: ReadonlyArray<BearerStaticTokenEntry>;
readonly headerName?: string;
readonly prefix?: string;
}
/** Zod schema mirroring {@link BearerStaticResolverOptions}. */
export const bearerStaticResolverOptionsSchema = z.object({
tokens: z
.array(
z.object({
token: z.string().min(1),
principal: z.object({
id: z.string().min(1),
scopes: z.array(z.string()),
tenantId: z.string().optional(),
claims: z.record(z.string(), z.unknown()),
}),
mtlsRequired: z.boolean().optional(),
})
)
.min(1),
headerName: z.string().min(1).optional(),
prefix: z.string().min(1).optional(),
});
/**
* Validate options at the factory boundary. Throws
* {@link BearerStaticAuthError} with `INVALID_OPTIONS` on failure.
*/
export function parseBearerStaticResolverOptions(
raw: unknown
): BearerStaticResolverOptions {
const result = bearerStaticResolverOptionsSchema.safeParse(raw);
if (!result.success) {
throw new BearerStaticAuthError(
AUTH_BEARER_STATIC_CODES.INVALID_OPTIONS,
`BearerStaticResolverOptions failed Zod validation: ${result.error.message}`,
{ cause: result.error }
);
}
// `safeParse` returns a Principal-shaped object; re-cast to the
// Principal interface (branded fields are erased at runtime and the
// host trusts operator-supplied config to brand correctly).
return result.data as unknown as BearerStaticResolverOptions;
}

View File

@@ -0,0 +1,6 @@
export {
type BearerStaticResolverOptions,
type BearerStaticTokenEntry,
bearerStaticResolverOptionsSchema,
parseBearerStaticResolverOptions,
} from "./bearer-static-resolver-options.js";

View File

@@ -0,0 +1,110 @@
# Worker-proxy factory — `@suaveplan/api-auth-bearer-static`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The worker-proxy surface is the second of the two Stage-4 surfaces this
adapter ships (`§5.2a`). It lets the host install the bearer-static
resolver in the main process while the actual token table lives inside an
isolated worker. The host's `SessionResolverChain` calls
`resolve(request)` exactly as for the direct surface; under the hood, the
proxy marshals the request across a `MessagePort` using
`@suaveplan/api-worker-rpc`, the worker's resolver runs, and the
`Principal | null` decision is returned to the host.
The proxy intentionally keeps the token table out of the host's address
space — useful when the host is sandboxed and the worker holds privileged
credentials, or when operators want a single auth daemon serving multiple
host workers.
## Features
- Bidirectional RPC over a `MessagePort` (or any `PortLike`) — no
process / address-space assumptions.
- Idempotent `dispose()` — coalesces multiple calls and closes the
underlying RPC client exactly once.
- Header set, request method, URL, and (optional) `peerCertificate` cross
the boundary; opaque body and abort signal are out of scope (resolution
does not need them).
- Server-side mount via `serveBearerStaticResolverOnPort` — owns the real
resolver and the token table.
- Passes the same `runAuthSessionConformance` harness as the direct
surface.
## Usage: Basic
```ts
import {
createBearerStaticResolverWorkerProxy,
serveBearerStaticResolverOnPort,
} from "@suaveplan/api-auth-bearer-static/proxy";
import { createInProcessPortPair } from "@suaveplan/api-worker-rpc";
const { clientPort, serverPort } = createInProcessPortPair();
// Worker side — owns the real table.
const server = serveBearerStaticResolverOnPort(serverPort, {
tokens: [
{
token: "T1",
principal: { id: "svc", scopes: [], claims: {} },
},
],
});
// Host side — sees only a proxy.
const proxy = createBearerStaticResolverWorkerProxy(clientPort);
const request = new Request("https://api/x", {
headers: { Authorization: "Bearer T1" },
});
const principal = await proxy.resolve(request);
await proxy.dispose();
server.close("done");
```
## Usage: Advanced
```ts
import { Worker, MessageChannel } from "node:worker_threads";
import {
createBearerStaticResolverWorkerProxy,
} from "@suaveplan/api-auth-bearer-static/proxy";
// Real Node worker boundary. The worker script imports
// `serveBearerStaticResolverOnPort` and calls it against its own port.
const { port1, port2 } = new MessageChannel();
const worker = new Worker("./auth-worker.mjs", { transferList: [port2] });
const resolver = createBearerStaticResolverWorkerProxy(port1 as never);
// Place in chain alongside direct adapters — host treats both uniformly.
import { SessionResolverChain } from "@suaveplan/api-auth";
const chain = new SessionResolverChain([resolver]);
```
## API Reference
- `serveBearerStaticResolverOnPort(port: PortLike, options: BearerStaticResolverOptions): { close(reason?: string): void }`
— mount the server-side handlers.
- `createBearerStaticResolverWorkerProxy(port: PortLike): SessionResolver`
— host-side proxy.
## Implementation Notes
The marshalled envelope is `{url, method, headers, peerCertificate?}` —
the minimal subset of `Request` that the bearer-static decision actually
needs. `peerCertificate` is structured-clone-friendly because the host
attaches whatever shape the listener surfaced; tests pass plain objects.
The proxy never sends the request body, because token resolution does not
consult it. Errors thrown by the server-side resolver (e.g. an unexpected
runtime fault) propagate as the standard `WORKER_RPC` error envelope and
surface in the host as a thrown `WorkerRpcError` — the host's resolver
chain treats this as a fail-closed `RESOLVER_THREW` per the documented
`api-auth` chain semantics. `dispose()` calls the remote `dispose` first
to drain the server-side state, then closes the local client so the
underlying port can be garbage-collected.

View File

@@ -0,0 +1,53 @@
import { runAuthSessionConformance } from "@suaveplan/api-auth";
import type { Principal } from "@suaveplan/api-types/principal";
import { createInProcessPortPair } from "@suaveplan/api-worker-rpc";
import {
createBearerStaticResolverWorkerProxy,
serveBearerStaticResolverOnPort,
} from "./create-bearer-static-resolver-worker-proxy.js";
const expectedPrincipal = {
id: "svc_cron",
scopes: ["jobs:run"],
claims: { kind: "service" },
} as unknown as Principal;
runAuthSessionConformance({
label: "bearer-static-worker-proxy",
factory: () => {
const pair = createInProcessPortPair();
const server = serveBearerStaticResolverOnPort(pair.serverPort, {
tokens: [
{
token: "T-CONFORMANCE",
principal: expectedPrincipal,
},
],
});
const proxy = createBearerStaticResolverWorkerProxy(pair.clientPort);
// Wrap dispose so closing the proxy also tears down the server +
// the channel — the conformance harness only calls dispose().
return {
resolve: (req) => proxy.resolve(req),
dispose: async () => {
await proxy.dispose();
try {
server.close();
} catch {
/* ignore */
}
pair.close();
},
};
},
seedNull: () =>
new Request("https://api/x", {
headers: { Authorization: "Bearer NOT_PRESENT" },
}),
seedPrincipal: () => ({
request: new Request("https://api/x", {
headers: { Authorization: "Bearer T-CONFORMANCE" },
}),
expected: expectedPrincipal,
}),
});

View File

@@ -0,0 +1,137 @@
import type { Principal } from "@suaveplan/api-types/principal";
import { createInProcessPortPair } from "@suaveplan/api-worker-rpc";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import {
createBearerStaticResolverWorkerProxy,
serveBearerStaticResolverOnPort,
} from "./create-bearer-static-resolver-worker-proxy.js";
const principal = {
id: "svc_a",
scopes: ["read"],
claims: { kind: "service" },
} as unknown as Principal;
interface Harness {
server: { close(reason?: string): void };
proxy: ReturnType<typeof createBearerStaticResolverWorkerProxy>;
pair: ReturnType<typeof createInProcessPortPair>;
}
function build(extraOpts: Record<string, unknown> = {}): Harness {
const pair = createInProcessPortPair();
const server = serveBearerStaticResolverOnPort(pair.serverPort, {
tokens: [
{ token: "T1", principal },
{
token: "T_MTLS",
principal,
mtlsRequired: true,
},
],
...extraOpts,
});
const proxy = createBearerStaticResolverWorkerProxy(pair.clientPort);
return { server, proxy, pair };
}
function teardown(h: Harness): void {
try {
h.server.close();
} catch {
/* ignore — server may already be closed */
}
h.pair.close();
}
describe("createBearerStaticResolverWorkerProxy", () => {
let active: Harness | undefined;
afterEach(async () => {
if (active !== undefined) {
await active.proxy.dispose().catch(() => undefined);
teardown(active);
active = undefined;
}
});
it("resolves a known token to its Principal", {
timeout: 5000,
}, async () => {
active = build();
const req = new Request("https://api/x", {
headers: { Authorization: "Bearer T1" },
});
const result = await active.proxy.resolve(req);
expect(result?.id).toBe("svc_a");
});
it("returns null for unknown tokens", { timeout: 5000 }, async () => {
active = build();
const req = new Request("https://api/x", {
headers: { Authorization: "Bearer NOPE" },
});
expect(await active.proxy.resolve(req)).toBeNull();
});
it("returns null when header is absent", {
timeout: 5000,
}, async () => {
active = build();
expect(
await active.proxy.resolve(new Request("https://api/x"))
).toBeNull();
});
it("ferries the peer certificate across the boundary", {
timeout: 5000,
}, async () => {
active = build();
const req = new Request("https://api/x", {
headers: { Authorization: "Bearer T_MTLS" },
});
Object.defineProperty(req, "tls", {
value: { peerCertificate: { subject: "CN=svc" } },
enumerable: true,
configurable: true,
});
expect((await active.proxy.resolve(req))?.id).toBe("svc_a");
});
it("rejects mtls-required token without peer certificate", {
timeout: 5000,
}, async () => {
active = build();
const req = new Request("https://api/x", {
headers: { Authorization: "Bearer T_MTLS" },
});
expect(await active.proxy.resolve(req)).toBeNull();
});
it("dispose is idempotent", { timeout: 5000 }, async () => {
const h = build();
await h.proxy.dispose();
await expect(h.proxy.dispose()).resolves.toBeUndefined();
// Subsequent resolves return null without crossing the boundary.
const req = new Request("https://api/x", {
headers: { Authorization: "Bearer T1" },
});
expect(await h.proxy.resolve(req)).toBeNull();
try {
h.server.close();
} catch {
/* ignore */
}
h.pair.close();
});
it("honours custom headerName + prefix on the server side", {
timeout: 5000,
}, async () => {
active = build({ headerName: "X-Svc", prefix: "Token" });
const req = new Request("https://api/x", {
headers: { "X-Svc": "Token T1" },
});
expect((await active.proxy.resolve(req))?.id).toBe("svc_a");
});
});

View File

@@ -0,0 +1,126 @@
import type { SessionResolver } from "@suaveplan/api-auth";
import type { Principal } from "@suaveplan/api-types/principal";
import type { PortLike } from "@suaveplan/api-worker-rpc";
import {
createWorkerRpcClient,
serveWorkerRpc,
} from "@suaveplan/api-worker-rpc";
import { createBearerStaticResolver } from "../factory/create-bearer-static-resolver.js";
import { extractPeerCertificate } from "../mtls/mtls-gate.js";
import {
type BearerStaticResolverOptions,
parseBearerStaticResolverOptions,
} from "../options/bearer-static-resolver-options.js";
/**
* Minimal serialisable Request envelope used to ferry a Request across the
* worker boundary. `peerCertificate` is intentionally JSON-stringified so
* we never push opaque DER buffers through `structuredClone`.
*/
interface SerialisedRequest {
readonly url: string;
readonly method: string;
readonly headers: ReadonlyArray<[string, string]>;
readonly peerCertificate?: unknown;
}
/**
* Methods the server side of the proxy mounts on its `MessagePort`. The
* client proxies them transparently via `createWorkerRpcClient`.
*/
interface BearerStaticProxyApi {
resolve(req: SerialisedRequest): Promise<Principal | null>;
dispose(): Promise<void>;
}
function serialiseRequest(request: Request): SerialisedRequest {
const headers: Array<[string, string]> = [];
request.headers.forEach((value, key) => {
headers.push([key, value]);
});
const cert = extractPeerCertificate(request);
return {
url: request.url,
method: request.method,
headers,
...(cert !== undefined ? { peerCertificate: cert } : {}),
};
}
function rehydrateRequest(env: SerialisedRequest): Request {
const headers = new Headers();
for (const [k, v] of env.headers) {
headers.set(k, v);
}
const request = new Request(env.url, {
method: env.method,
headers,
});
if (env.peerCertificate !== undefined) {
Object.defineProperty(request, "tls", {
value: { peerCertificate: env.peerCertificate },
enumerable: true,
configurable: true,
});
}
return request;
}
/**
* Mount a server-side bearer-static resolver on `port`. Returns a disposer.
*
* Lives "on the worker side" of the boundary — the real token table never
* crosses into the host process. The host installs the client side via
* {@link createBearerStaticResolverWorkerProxy}.
*/
export function serveBearerStaticResolverOnPort(
port: PortLike,
options: BearerStaticResolverOptions | unknown
): { close(reason?: string): void } {
const opts = parseBearerStaticResolverOptions(options);
const resolver = createBearerStaticResolver(opts);
const server = serveWorkerRpc(port, {
resolve: async (params: ReadonlyArray<unknown>) => {
const env = params[0] as SerialisedRequest;
const request = rehydrateRequest(env);
return resolver.resolve(request);
},
dispose: async () => {
await resolver.dispose();
},
});
return server;
}
/**
* Build a client-side {@link SessionResolver} that proxies every call to
* a server resolver running on the other end of `port`. The host treats
* the returned proxy identically to the direct surface.
*
* `dispose()` is idempotent — multiple calls are coalesced into a single
* remote `dispose` invocation, and the underlying RPC client is closed
* on the first call.
*/
export function createBearerStaticResolverWorkerProxy(
port: PortLike
): SessionResolver {
const client = createWorkerRpcClient<BearerStaticProxyApi>(port);
let disposed = false;
return {
async resolve(req: Request): Promise<Principal | null> {
if (disposed) return null;
const env = serialiseRequest(req);
const result = await client.resolve(env);
return result;
},
async dispose(): Promise<void> {
if (disposed) return;
disposed = true;
try {
await client.dispose();
} finally {
client.close("proxy disposed");
}
},
};
}

View File

@@ -0,0 +1,4 @@
export {
createBearerStaticResolverWorkerProxy,
serveBearerStaticResolverOnPort,
} from "./create-bearer-static-resolver-worker-proxy.js";

View File

@@ -0,0 +1,4 @@
export {
type SecuritySchemeContributionOptions,
securitySchemeContribution,
} from "./security-scheme-contribution.js";

View File

@@ -0,0 +1,71 @@
# Security scheme contribution — `@suaveplan/api-auth-bearer-static`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module builds the OpenAPI 3.1 `components.securitySchemes`
contribution the adapter publishes to the host's `AuthAdapter` registry.
The generated entry declares an `http` / `bearer` scheme so OpenAPI tooling
(Stoplight, ReadMe, Postman import, etc.) and the host's emitter render
the canonical "Authorize" affordance.
The contribution is content-only: it does NOT register routes, mount
middleware, or affect runtime behaviour. It exists solely so the OpenAPI
document the host emits is correct and discoverable.
## Features
- OAS 3.1 compliant — emits the `http` discriminator with `scheme: bearer`.
- `name` defaults to `bearerStaticAuth` and is overridable at construction.
- Optional `bearerFormat` and `description` fields pass through verbatim.
- Validated against the `OpenApiSecuritySchemeSchema` Zod schema published
by `@suaveplan/api-auth/security-scheme` — if this module ever drifts,
the conformance tests in `api-auth` fail.
## Usage: Basic
```ts
import { securitySchemeContribution } from "@suaveplan/api-auth-bearer-static/security-scheme";
const contrib = securitySchemeContribution();
// { name: "bearerStaticAuth",
// scheme: { type: "http", scheme: "bearer" } }
```
## Usage: Advanced
```ts
import { securitySchemeContribution } from "@suaveplan/api-auth-bearer-static/security-scheme";
import { validateSecurityScheme } from "@suaveplan/api-auth";
// Custom name + bearerFormat for an admin-only deployment that publishes
// two static-token adapters (cron + admin) side by side under distinct
// scheme keys.
const adminScheme = securitySchemeContribution({
name: "bearerStaticAdminAuth",
description: "Static admin token. mTLS-gated. Rotate via host config reload.",
bearerFormat: "opaque",
});
// Defensive — confirm the contribution still passes the api-auth schema.
validateSecurityScheme(adminScheme);
```
## API Reference
- `interface SecuritySchemeContributionOptions { name?: string; description?: string; bearerFormat?: string }`
- `securitySchemeContribution(options?: SecuritySchemeContributionOptions): SecuritySchemeContribution`
— pure factory; the result is structurally a
`SecuritySchemeContribution` from `@suaveplan/api-auth`.
## Implementation Notes
`bearerFormat` is purely informational per OAS — values like `JWT`,
`opaque`, or vendor-specific strings appear in some doc tooling but never
affect runtime validation. The contribution intentionally omits the
optional fields when undefined rather than emitting `undefined` values,
which would round-trip through `JSON.stringify` as missing keys but
trigger noisy diffs in machine-comparison tooling.

View File

@@ -0,0 +1,62 @@
import { validateSecurityScheme } from "@suaveplan/api-auth";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { securitySchemeContribution } from "./security-scheme-contribution.js";
describe("securitySchemeContribution", () => {
it("returns the default HTTP-Bearer scheme", { timeout: 5000 }, () => {
const contrib = securitySchemeContribution();
expect(contrib.name).toBe("bearerStaticAuth");
expect(contrib.scheme).toEqual({
type: "http",
scheme: "bearer",
});
});
it("honours overridden name", { timeout: 5000 }, () => {
const contrib = securitySchemeContribution({
name: "bearerStaticAdminAuth",
});
expect(contrib.name).toBe("bearerStaticAdminAuth");
});
it("emits bearerFormat when supplied", { timeout: 5000 }, () => {
const contrib = securitySchemeContribution({
bearerFormat: "opaque",
});
expect(contrib.scheme).toEqual({
type: "http",
scheme: "bearer",
bearerFormat: "opaque",
});
});
it("emits description when supplied", { timeout: 5000 }, () => {
const contrib = securitySchemeContribution({
description: "Static service tokens",
});
expect(contrib.scheme).toEqual({
type: "http",
scheme: "bearer",
description: "Static service tokens",
});
});
it("omits undefined optional fields", { timeout: 5000 }, () => {
const contrib = securitySchemeContribution({
bearerFormat: undefined,
description: undefined,
});
expect(Object.keys(contrib.scheme)).toEqual(["type", "scheme"]);
});
it("passes validation against @suaveplan/api-auth schema", {
timeout: 5000,
}, () => {
const contrib = securitySchemeContribution({
description: "x",
bearerFormat: "opaque",
});
const validated = validateSecurityScheme(contrib);
expect(validated.name).toBe("bearerStaticAuth");
});
});

View File

@@ -0,0 +1,43 @@
import type { SecuritySchemeContribution } from "@suaveplan/api-auth";
/**
* Options for {@link securitySchemeContribution}.
*
* `name` is the key under `components.securitySchemes` in the generated
* OpenAPI document. Defaults to `"bearerStaticAuth"`.
*
* `description` is the OAS `description` field on the scheme object.
*
* `bearerFormat` is the OAS `bearerFormat` field — purely informational;
* the adapter does not validate it.
*/
export interface SecuritySchemeContributionOptions {
readonly name?: string;
readonly description?: string;
readonly bearerFormat?: string;
}
/**
* Build the OpenAPI 3.1 `securitySchemes` contribution for this adapter.
*
* Always emits an HTTP-Bearer scheme — the package's contract is bearer
* tokens regardless of the configured `headerName` / `prefix`. Custom
* header names are an operational deployment concern that does not surface
* in the public OpenAPI document.
*/
export function securitySchemeContribution(
options: SecuritySchemeContributionOptions = {}
): SecuritySchemeContribution {
const name = options.name ?? "bearerStaticAuth";
const scheme: SecuritySchemeContribution["scheme"] = {
type: "http",
scheme: "bearer",
...(options.bearerFormat !== undefined
? { bearerFormat: options.bearerFormat }
: {}),
...(options.description !== undefined
? { description: options.description }
: {}),
};
return { name, scheme };
}

View File

@@ -0,0 +1,7 @@
export {
type BearerStaticInstruments,
type BearerStaticMatchOutcome,
getBearerStaticInstruments,
recordMatchOutcome,
resetBearerStaticInstruments,
} from "./instruments.js";

View File

@@ -0,0 +1,76 @@
# OTel instruments — `@suaveplan/api-auth-bearer-static`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The telemetry module owns the single OpenTelemetry counter this package
emits — `api.auth.bearer_static_match_total` — partitioned by the
documented `outcome` attribute. Every call to a resolver's `resolve()`
method increments this counter exactly once with one of four label values.
The instrument is bound lazily against `metrics.getMeter(...)` so the
package is safe to import on the cold path before the host installs a
`MeterProvider`. Resetting the cached bundle (via
`resetBearerStaticInstruments`) is required in test setup to bind against
a freshly-installed in-memory exporter.
## Features
- Single counter — `api.auth.bearer_static_match_total`.
- Four `outcome` label values: `match`, `no-token`, `unknown-token`,
`mtls-required`.
- Lazy meter resolution — module load does not trigger an OTel side
effect.
- Idempotent reset hook for test isolation.
## Usage: Basic
```ts
import { recordMatchOutcome } from "@suaveplan/api-auth-bearer-static/src/telemetry";
// Inside a resolver's resolve() body:
recordMatchOutcome("unknown-token");
```
## Usage: Advanced
```ts
import {
getBearerStaticInstruments,
resetBearerStaticInstruments,
} from "@suaveplan/api-auth-bearer-static/src/telemetry";
// In a vitest setup file that has installed an in-memory MeterProvider
// upstream:
beforeEach(() => {
resetBearerStaticInstruments();
});
it("emits the outcome attribute we expect", async () => {
const inst = getBearerStaticInstruments();
inst.matchTotal.add(1, { outcome: "match" });
// ...then flush + assert on the exporter
});
```
## API Reference
- `interface BearerStaticInstruments { matchTotal: Counter }`
- `type BearerStaticMatchOutcome = "match" | "no-token" | "unknown-token" | "mtls-required"`
- `getBearerStaticInstruments(): BearerStaticInstruments`
- `resetBearerStaticInstruments(): void`
- `recordMatchOutcome(outcome: BearerStaticMatchOutcome): void`
## Implementation Notes
The instrument name uses dotted notation per OpenTelemetry semantic
conventions; the attribute key is `outcome` rather than the host-wide
`auth.outcome` since this counter is adapter-scoped and dashboards aggregate
across adapters by metric name, not attribute. The `description` field is
human-readable only — OTel collectors do not interpret it. The cached bundle
exists per process and is intentionally global; resolvers do not own
instruments because every concrete resolver in a chain would otherwise
double-publish the same counter under the same scope.

View File

@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
import {
getBearerStaticInstruments,
recordMatchOutcome,
resetBearerStaticInstruments,
} from "./instruments.js";
function findCounter(
name: string,
metrics: Awaited<ReturnType<typeof flushTestMetrics>>
): { value: number; attributes: Record<string, unknown> }[] {
const out: { value: number; attributes: Record<string, unknown> }[] = [];
for (const rm of metrics) {
for (const sm of rm.scopeMetrics) {
for (const m of sm.metrics) {
if (m.descriptor.name !== name) continue;
for (const dp of m.dataPoints) {
out.push({
value: Number(dp.value),
attributes: (dp.attributes ?? {}) as Record<
string,
unknown
>,
});
}
}
}
}
return out;
}
describe("BearerStatic OTel instruments", () => {
beforeEach(() => {
resetBearerStaticInstruments();
});
it("caches the bundle across calls", { timeout: 5000 }, () => {
const a = getBearerStaticInstruments();
const b = getBearerStaticInstruments();
expect(a).toBe(b);
});
it("resetBearerStaticInstruments forces re-bind", {
timeout: 5000,
}, () => {
const a = getBearerStaticInstruments();
resetBearerStaticInstruments();
const b = getBearerStaticInstruments();
expect(a).not.toBe(b);
});
it("recordMatchOutcome emits the partitioned counter", {
timeout: 5000,
}, async () => {
await flushTestMetrics();
recordMatchOutcome("match");
recordMatchOutcome("unknown-token");
recordMatchOutcome("no-token");
recordMatchOutcome("mtls-required");
const collected = await flushTestMetrics();
const points = findCounter(
"api.auth.bearer_static_match_total",
collected
);
const outcomes = points.map((p) => p.attributes.outcome);
expect(outcomes).toContain("match");
expect(outcomes).toContain("unknown-token");
expect(outcomes).toContain("no-token");
expect(outcomes).toContain("mtls-required");
});
});

View File

@@ -0,0 +1,52 @@
import { type Counter, metrics } from "@opentelemetry/api";
/**
* Bundle of OpenTelemetry instruments emitted by
* `@suaveplan/api-auth-bearer-static`.
*
* - `matchTotal{outcome}` — incremented exactly once per `resolve()` call.
* `outcome` is one of:
* - `"match"` — known token, mTLS gate (if any) satisfied.
* - `"no-token"` — no Authorization header / wrong scheme.
* - `"unknown-token"` — header present, token not in table.
* - `"mtls-required"` — token matched but mTLS gate tripped.
*/
export interface BearerStaticInstruments {
readonly matchTotal: Counter;
}
let cached: BearerStaticInstruments | undefined;
/**
* Lazily build (and cache) the instruments bundle against the current
* global `MeterProvider`. Call {@link resetBearerStaticInstruments} in
* test setup to force a re-bind against a freshly-installed provider.
*/
export function getBearerStaticInstruments(): BearerStaticInstruments {
if (cached !== undefined) return cached;
const meter = metrics.getMeter("@suaveplan/api-auth-bearer-static");
cached = {
matchTotal: meter.createCounter("api.auth.bearer_static_match_total", {
description:
"Total bearer-static resolve() outcomes, partitioned by outcome attribute (match | no-token | unknown-token | mtls-required)",
}),
};
return cached;
}
/** Drop the cached instrument bundle so the next call re-binds. */
export function resetBearerStaticInstruments(): void {
cached = undefined;
}
/** Stable outcome label values for {@link BearerStaticInstruments.matchTotal}. */
export type BearerStaticMatchOutcome =
| "match"
| "no-token"
| "unknown-token"
| "mtls-required";
/** Convenience wrapper that records one match outcome. */
export function recordMatchOutcome(outcome: BearerStaticMatchOutcome): void {
getBearerStaticInstruments().matchTotal.add(1, { outcome });
}

View File

@@ -0,0 +1,37 @@
/**
* §16 conformance proof — the worker-proxy package consumes the
* `@suaveplan/api-worker-rpc` contract directly (client + server +
* MessagePort transport). This test exercises the canonical
* `runWorkerRpcConformance` harness with a minimal handler set to
* prove the consumer is wire-compatible end-to-end (request frame,
* response frame, abort propagation) using the same port transport
* the package builds on top of in `src/main-side` / `src/worker-side`.
*/
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { runWorkerRpcConformance } from "../../../api-worker-rpc/src/testing/conformance.js";
const handles: Array<{ dispose(): void }> = [];
afterEach(() => {
while (handles.length > 0) handles.pop()?.dispose();
});
describe("api-worker-rpc contract conformance (consumer-side)", () => {
it("round-trips a request through the canonical harness", {
timeout: 5000,
}, async () => {
const h = runWorkerRpcConformance(() => ({
echo: (params: ReadonlyArray<unknown>) => params[0],
}));
handles.push(h);
const result = await h.client.call<number>("echo", [42]);
expect(result).toBe(42);
});
it("surfaces UNKNOWN_METHOD from the server", {
timeout: 5000,
}, async () => {
const h = runWorkerRpcConformance(() => ({}));
handles.push(h);
await expect(h.client.call("missing", [])).rejects.toThrow();
});
});

View File

@@ -0,0 +1,33 @@
/**
* Test setup for @suaveplan/api-auth-bearer-static.
*
* Installs a real in-memory OpenTelemetry MeterProvider so OTel
* instruments resolved on first call bind against an exporter the
* tests can flush + read.
*/
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter: testInMemoryMetricExporter,
exportIntervalMillis: 60_000,
});
export const testMeterProvider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(testMeterProvider);
export async function flushTestMetrics() {
await testMeterProvider.forceFlush();
const collected = testInMemoryMetricExporter.getMetrics();
testInMemoryMetricExporter.reset();
return collected;
}

View 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"]
}

View 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"]
}

View File

@@ -0,0 +1,36 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/api-auth-bearer-static",
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\//,
/^@opentelemetry\//,
/^node:/,
"tslib",
"zod",
],
},
},
});

View File

@@ -0,0 +1,32 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
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,
},
});