Registers the TECH_LOG_STUDIO_SESSION SAME_ORIGIN_COOKIE auth profile and declares the tech-log-studio-http-v1 contribution covering all 18 JSON Studio operations (everything except the multipart uploadStudioAsset), built from two shared builders (safeOperation/keyedOperation) so every KEYED command gets IDEMPOTENCY_REPLAY recovery and a zero retry budget, and every SAFE read gets a plain retry budget, without repeating the declaration shape 18 times. Rescopes the canonical package identity from "tech-log-studio-contract" to "@tech-log/studio-contract" (generator, canonical-source.json, contract-generation.test.ts) because the platform's contribution composer requires an npm-scoped packageId; the unscoped form failed composition. Updates the release-manifest test fixture, which hardcoded an empty expected contract set, to derive its expected packages from the real installed set now that TechLog is always installed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
315 lines
10 KiB
TypeScript
315 lines
10 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([]),
|
|
}),
|
|
/**
|
|
* TechLog Studio session profile. Canonical mandates a session cookie plus
|
|
* `X-CSRF-TOKEN` on every mutating Studio operation; the platform already
|
|
* has this combination first-class as `SAME_ORIGIN_COOKIE` credentials with
|
|
* an `x-csrf-token` credential header.
|
|
*/
|
|
TECH_LOG_STUDIO_SESSION: Object.freeze({
|
|
authProfileId: "TECH_LOG_STUDIO_SESSION",
|
|
transport: "SAME_ORIGIN_COOKIE",
|
|
credentials: "include",
|
|
allowedCredentialHeaders: Object.freeze(["x-csrf-token"] as const),
|
|
requiredCredentialHeaders: Object.freeze(["x-csrf-token"] as const),
|
|
}),
|
|
} 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;
|
|
}
|