LIVE-01. A credential collaborator that returns UNAVAILABLE, throws, rejects or answers off-contract is an outage of the auth integration, not evidence about the user's session. Each of those now closes as AUTH_INTEGRATION_FAILURE with zero fetches, so the composition root's logout path stays reserved for a genuinely absent session. The synchronous and asynchronous failure sites share one classifier. LIVE-02 / LIVE-03. Object.freeze(new Map(...)) freezes the wrapper, not the backing store, so an exported registry could still be cleared or replaced after composition. Both the installed REST auth profile registry and the composed HTTP/event lookups are now read facades over private stores, and every composed row is an exact own-data snapshot that rejects accessors, inherited and symbol-keyed fields. LIVE-04. The total deadline now bounds the physical waits rather than being checked between them: dispatch and response admission race the attempt signal, the bounded reader takes that signal, and an abandoned operation is still observed once so a late native rejection cannot surface unhandled. A body that completes after the deadline or the caller owns the execution is no longer admitted; a stale generation keeps its more specific SCOPE_FENCED verdict. LIVE-05. DEADLINE is no longer treated as a caller-owned cancellation, so a timeout reaches api.request.failed exactly once while caller, route, scope and shutdown aborts stay excluded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
302 lines
9.6 KiB
TypeScript
302 lines
9.6 KiB
TypeScript
import {
|
|
createReadOnlyRegistry,
|
|
type ReadOnlyRegistry,
|
|
} from "./read-only-registry.ts";
|
|
|
|
export type FetchCredentialsMode = "omit" | "same-origin" | "include";
|
|
|
|
export type RestProviderProfile = Readonly<{
|
|
providerId: string;
|
|
baseUrl: string;
|
|
allowedCredentialsModes: readonly FetchCredentialsMode[];
|
|
redirect: "error";
|
|
referrerPolicy: "no-referrer";
|
|
}>;
|
|
|
|
/**
|
|
* VD-23. The complete closed set of headers a credential owner may contribute.
|
|
* Transport-owned headers (`accept`, `content-type`, `idempotency-key`) and
|
|
* every forbidden request header are deliberately absent.
|
|
*/
|
|
export const CREDENTIAL_HEADER_NAMES = Object.freeze([
|
|
"authorization",
|
|
"x-csrf-token",
|
|
"x-tenant-context",
|
|
] as const);
|
|
|
|
export type CredentialHeaderName = (typeof CREDENTIAL_HEADER_NAMES)[number];
|
|
|
|
export type RestAuthProfile = Readonly<{
|
|
authProfileId: string;
|
|
transport: "ANONYMOUS" | "BEARER_HEADER" | "SAME_ORIGIN_COOKIE";
|
|
credentials: FetchCredentialsMode;
|
|
allowedCredentialHeaders: readonly CredentialHeaderName[];
|
|
/**
|
|
* Proof headers the transport must observe before dispatch. A missing entry
|
|
* fails closed with zero `fetch()` calls rather than sending an anonymous
|
|
* request under an authenticated profile.
|
|
*/
|
|
requiredCredentialHeaders: readonly CredentialHeaderName[];
|
|
}>;
|
|
|
|
/**
|
|
* LIVE-02. A read facade over a private store, never a `Map`. The executor
|
|
* resolves a profile on every request, so a post-installation `clear()` would
|
|
* otherwise turn every authenticated call into `UNKNOWN_AUTH_PROFILE`.
|
|
*/
|
|
export type InstalledRestAuthProfiles = ReadOnlyRegistry<
|
|
string,
|
|
RestAuthProfile
|
|
>;
|
|
|
|
export type RestCsrfProfile = Readonly<{
|
|
csrfProfileId: string;
|
|
mode: "NONE" | "HEADER";
|
|
headerName: "x-csrf-token" | null;
|
|
}>;
|
|
|
|
export const REST_AUTH_PROFILES = Object.freeze({
|
|
REFERENCE_EXTERNAL_BEARER: Object.freeze({
|
|
authProfileId: "REFERENCE_EXTERNAL_BEARER",
|
|
transport: "BEARER_HEADER",
|
|
credentials: "omit",
|
|
allowedCredentialHeaders: Object.freeze(["authorization"] as const),
|
|
requiredCredentialHeaders: Object.freeze(["authorization"] as const),
|
|
}),
|
|
ANONYMOUS: Object.freeze({
|
|
authProfileId: "ANONYMOUS",
|
|
transport: "ANONYMOUS",
|
|
credentials: "omit",
|
|
allowedCredentialHeaders: Object.freeze([]),
|
|
requiredCredentialHeaders: Object.freeze([]),
|
|
}),
|
|
} satisfies Readonly<Record<string, RestAuthProfile>>);
|
|
|
|
function isCredentialHeaderName(value: unknown): value is CredentialHeaderName {
|
|
return (
|
|
typeof value === "string" &&
|
|
(CREDENTIAL_HEADER_NAMES as readonly string[]).includes(value)
|
|
);
|
|
}
|
|
|
|
function exactHeaderSet(
|
|
names: unknown,
|
|
label: string,
|
|
): readonly CredentialHeaderName[] {
|
|
if (!Array.isArray(names)) {
|
|
throw new TypeError(`REST auth profile ${label} must be an array.`);
|
|
}
|
|
const seen = new Set<string>();
|
|
for (const name of names) {
|
|
if (!isCredentialHeaderName(name) || seen.has(name)) {
|
|
throw new TypeError(`REST auth profile ${label} is not an exact set.`);
|
|
}
|
|
seen.add(name);
|
|
}
|
|
return Object.freeze([...(names as readonly CredentialHeaderName[])]);
|
|
}
|
|
|
|
/**
|
|
* §7.7 / VD-23. Installs the composition-owned auth profile registry once.
|
|
*
|
|
* The registry — not a credential collaborator — owns Fetch `credentials` and
|
|
* the exact allowed/required credential-header sets. An incoherent profile is a
|
|
* composition failure, never a runtime downgrade.
|
|
*/
|
|
export function installRestAuthProfileRegistry(
|
|
profiles: Readonly<Record<string, RestAuthProfile>> = REST_AUTH_PROFILES,
|
|
): InstalledRestAuthProfiles {
|
|
const installed = new Map<string, RestAuthProfile>();
|
|
for (const [key, candidate] of Object.entries(profiles)) {
|
|
if (!candidate || typeof candidate !== "object") {
|
|
throw new TypeError(`REST auth profile ${key} is not an object.`);
|
|
}
|
|
const authProfileId = candidate.authProfileId;
|
|
if (
|
|
typeof authProfileId !== "string" ||
|
|
authProfileId.length === 0 ||
|
|
authProfileId !== key
|
|
) {
|
|
throw new TypeError(`REST auth profile ${key} has a mismatched identity.`);
|
|
}
|
|
const allowed = exactHeaderSet(
|
|
candidate.allowedCredentialHeaders,
|
|
"allowedCredentialHeaders",
|
|
);
|
|
const required = exactHeaderSet(
|
|
candidate.requiredCredentialHeaders,
|
|
"requiredCredentialHeaders",
|
|
);
|
|
if (!required.every((name) => allowed.includes(name))) {
|
|
throw new TypeError(
|
|
`REST auth profile ${key} requires a header it does not allow.`,
|
|
);
|
|
}
|
|
const credentials = candidate.credentials;
|
|
if (!["omit", "same-origin", "include"].includes(credentials)) {
|
|
throw new TypeError(`REST auth profile ${key} has invalid credentials.`);
|
|
}
|
|
switch (candidate.transport) {
|
|
case "ANONYMOUS":
|
|
if (
|
|
credentials !== "omit" ||
|
|
allowed.length > 0 ||
|
|
required.length > 0
|
|
) {
|
|
throw new TypeError(
|
|
`Anonymous REST auth profile ${key} cannot carry credentials.`,
|
|
);
|
|
}
|
|
break;
|
|
case "BEARER_HEADER":
|
|
if (credentials !== "omit" || !required.includes("authorization")) {
|
|
throw new TypeError(
|
|
`Bearer REST auth profile ${key} must require authorization with omitted credentials.`,
|
|
);
|
|
}
|
|
break;
|
|
case "SAME_ORIGIN_COOKIE":
|
|
if (credentials === "omit" || allowed.includes("authorization")) {
|
|
throw new TypeError(
|
|
`Cookie REST auth profile ${key} must send ambient credentials without a bearer header.`,
|
|
);
|
|
}
|
|
break;
|
|
default:
|
|
throw new TypeError(`REST auth profile ${key} has unknown transport.`);
|
|
}
|
|
installed.set(
|
|
authProfileId,
|
|
Object.freeze({
|
|
authProfileId,
|
|
transport: candidate.transport,
|
|
credentials,
|
|
allowedCredentialHeaders: allowed,
|
|
requiredCredentialHeaders: required,
|
|
}),
|
|
);
|
|
}
|
|
if (installed.size === 0) {
|
|
throw new TypeError("REST auth profile registry cannot be empty.");
|
|
}
|
|
return createReadOnlyRegistry(installed);
|
|
}
|
|
|
|
/** The single installed registry every composition root shares. */
|
|
export const INSTALLED_REST_AUTH_PROFILES: InstalledRestAuthProfiles =
|
|
installRestAuthProfileRegistry();
|
|
|
|
export const REST_CSRF_PROFILES = Object.freeze({
|
|
NO_CSRF_BEARER: Object.freeze({
|
|
csrfProfileId: "NO_CSRF_BEARER",
|
|
mode: "NONE",
|
|
headerName: null,
|
|
}),
|
|
} satisfies Readonly<Record<string, RestCsrfProfile>>);
|
|
|
|
export function createRestProviderProfile(
|
|
providerId: string,
|
|
baseUrl: string,
|
|
allowedCredentialsModes: readonly FetchCredentialsMode[] = ["omit"],
|
|
): RestProviderProfile {
|
|
const parsed = new URL(baseUrl);
|
|
const localHttp =
|
|
parsed.protocol === "http:" &&
|
|
["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname);
|
|
if (
|
|
!providerId ||
|
|
(parsed.protocol !== "https:" && !localHttp) ||
|
|
parsed.username ||
|
|
parsed.password ||
|
|
parsed.search ||
|
|
parsed.hash ||
|
|
allowedCredentialsModes.length === 0 ||
|
|
new Set(allowedCredentialsModes).size !== allowedCredentialsModes.length
|
|
) {
|
|
throw new TypeError("Invalid REST provider profile.");
|
|
}
|
|
return Object.freeze({
|
|
providerId,
|
|
baseUrl: parsed.href,
|
|
allowedCredentialsModes: Object.freeze([...allowedCredentialsModes]),
|
|
redirect: "error",
|
|
referrerPolicy: "no-referrer",
|
|
});
|
|
}
|
|
|
|
export function resolveRestSecurityProfiles(
|
|
operation: Readonly<{
|
|
method: string;
|
|
auth: "none" | "external-session";
|
|
authProfileId?: string;
|
|
csrfProfileId?: string;
|
|
}>,
|
|
provider: RestProviderProfile,
|
|
authProfiles: Readonly<Record<string, RestAuthProfile>> = REST_AUTH_PROFILES,
|
|
csrfProfiles: Readonly<Record<string, RestCsrfProfile>> = REST_CSRF_PROFILES,
|
|
): Readonly<{ auth: RestAuthProfile; csrf: RestCsrfProfile }> {
|
|
const auth = authProfiles[operation.authProfileId ?? ""];
|
|
const csrf = csrfProfiles[operation.csrfProfileId ?? ""];
|
|
const unsafe = !["GET", "HEAD", "OPTIONS"].includes(operation.method);
|
|
if (
|
|
!auth ||
|
|
!csrf ||
|
|
!provider.allowedCredentialsModes.includes(auth.credentials) ||
|
|
(operation.auth === "none" && auth.transport !== "ANONYMOUS") ||
|
|
(operation.auth === "external-session" &&
|
|
auth.transport === "ANONYMOUS") ||
|
|
(auth.transport === "BEARER_HEADER" && csrf.mode !== "NONE") ||
|
|
(unsafe &&
|
|
auth.transport === "SAME_ORIGIN_COOKIE" &&
|
|
csrf.mode !== "HEADER")
|
|
) {
|
|
throw new TypeError("REST security profiles are incoherent.");
|
|
}
|
|
return Object.freeze({ auth, csrf });
|
|
}
|
|
|
|
export function validateRestProfileBindings(
|
|
operations: Readonly<
|
|
Record<
|
|
string,
|
|
Readonly<{
|
|
contractVersion?: number;
|
|
operationId: string;
|
|
method: string;
|
|
auth: "none" | "external-session";
|
|
providerId?: string;
|
|
authProfileId?: string;
|
|
csrfProfileId?: string;
|
|
}>
|
|
>
|
|
>,
|
|
providerCredentialModes: Readonly<
|
|
Record<string, readonly FetchCredentialsMode[]>
|
|
>,
|
|
authProfiles: Readonly<Record<string, RestAuthProfile>> = REST_AUTH_PROFILES,
|
|
csrfProfiles: Readonly<Record<string, RestCsrfProfile>> = REST_CSRF_PROFILES,
|
|
): true {
|
|
for (const operation of Object.values(operations)) {
|
|
if (operation.contractVersion !== 2) continue;
|
|
const allowed = providerCredentialModes[operation.providerId ?? ""];
|
|
if (!allowed) {
|
|
throw new TypeError(
|
|
`Unregistered REST provider binding: ${operation.operationId}`,
|
|
);
|
|
}
|
|
resolveRestSecurityProfiles(
|
|
operation,
|
|
Object.freeze({
|
|
providerId: operation.providerId ?? "",
|
|
baseUrl: "https://contract.invalid/",
|
|
allowedCredentialsModes: allowed,
|
|
redirect: "error",
|
|
referrerPolicy: "no-referrer",
|
|
}),
|
|
authProfiles,
|
|
csrfProfiles,
|
|
);
|
|
}
|
|
return true;
|
|
}
|