Files
skills/docs/api-stack/api/api-auth/src/resolver/resolver.md
2026-05-29 08:20:56 +01:00

4.1 KiB

resolver — SessionResolver contract

(no submodules)

Purpose

SessionResolver is the abstract per-request session-resolver shape that every concrete adapter in Stages 4/6/8 (api-auth-jwt, api-auth-anonymous, api-auth-bearer-static, api-auth-better-auth, api-auth-rolled) implements. It deliberately captures only the universal vocabulary — inspect a WHATWG Request, return a Principal | null, dispose — so any concrete resolver can plug behind the host's chain runner without further coordination.

The tri-valued return contract (principal / null / throw) collapses to two host-level outcomes: the chain short-circuits on the first principal, the host applies its publicRoutes decision when every resolver returned null, and AUTH.RESOLVER_THREW is surfaced when any resolver throws (fail-closed by default per design doc §6).

Features

  • Generic over P extends Principal — concrete adapters MAY narrow Principal via interface merging without losing assignability to the chain's Principal return type.
  • Async by construction; resolvers MAY fetch JWKs, hit Redis, or call a token introspection endpoint.
  • dispose is part of the contract, not a side concern — the host blue/green rotates resolver chains and relies on dispose to release JWK caches, close HTTP keepalive pools, etc.

Usage: Basic

A minimal resolver that always returns null (the "anonymous fallback" pattern codified in api-auth-anonymous):

import type { SessionResolver } from "@suaveplan/api-auth/resolver";

const anonymous: SessionResolver = {
    async resolve() {
        return null;
    },
    async dispose() {
        /* no resources to release */
    },
};

Usage: Advanced

A resolver that reads a Bearer token, parses claims, and returns a typed principal — with dispose releasing a token-cache eviction timer:

import type { SessionResolver } from "@suaveplan/api-auth/resolver";
import type { Principal, PrincipalId, Scope } from "@suaveplan/api-types/principal";

interface JwtClaims {
    sub: string;
    scope?: string;
    [key: string]: unknown;
}

class JwtResolver implements SessionResolver {
    private readonly evictTimer: ReturnType<typeof setInterval>;
    private disposed = false;

    public constructor(private readonly verifyJwt: (raw: string) => Promise<JwtClaims | null>) {
        this.evictTimer = setInterval(() => {
            /* prune cache */
        }, 30_000);
    }

    public async resolve(req: Request): Promise<Principal | null> {
        const header = req.headers.get("authorization");
        if (!header?.startsWith("Bearer ")) {
            return null;
        }
        const claims = await this.verifyJwt(header.slice("Bearer ".length));
        if (claims === null) {
            return null;
        }
        const scopes = (claims.scope ?? "").split(" ").filter(Boolean) as readonly Scope[];
        return {
            id: claims.sub as PrincipalId,
            scopes,
            claims,
        };
    }

    public async dispose(): Promise<void> {
        if (this.disposed) {
            return;
        }
        this.disposed = true;
        clearInterval(this.evictTimer);
    }
}

API Reference

Symbol Kind Description
SessionResolver interface resolve(req): Promise<P | null> + dispose().
P (generic) type param Defaults to Principal. Narrowing is opt-in.

Implementation Notes

  • Request is the WHATWG Fetch Request (DOM lib). Node ≥18, Bun, Deno, and every Workers runtime all expose it as a global; api-auth does not import any Node-specific request type so the contract is universal.
  • Returning null for "credential present but invalid" is acceptable but discouraged — emit AUTH.PRINCIPAL_INVALID_SHAPE instead, so the host can distinguish "no credential" from "bad credential" in metrics.
  • dispose SHOULD be idempotent; tests in the conformance harness call it twice in a row and assert no throw.