Files
tech-log-frontend/src/contracts/rest-profiles.ts
T
DongHyeonkaandClaude Opus 5 66c047cec8 feat: register the TechLog Studio contract contribution
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>
2026-08-18 01:10:04 +09:00

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;
}