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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a6fc536d8a
commit
66c047cec8
@@ -88,7 +88,7 @@ const canonicalBytes = readFileSync(CANONICAL_YAML);
|
|||||||
const canonicalText = canonicalBytes.toString("utf8");
|
const canonicalText = canonicalBytes.toString("utf8");
|
||||||
|
|
||||||
const record: CanonicalRecord = {
|
const record: CanonicalRecord = {
|
||||||
packageId: "tech-log-studio-contract",
|
packageId: "@tech-log/studio-contract",
|
||||||
version: specVersionOf(canonicalText),
|
version: specVersionOf(canonicalText),
|
||||||
digest: digestOf(canonicalBytes),
|
digest: digestOf(canonicalBytes),
|
||||||
sourceRevision: execFileSync(
|
sourceRevision: execFileSync(
|
||||||
|
|||||||
@@ -70,6 +70,19 @@ export const REST_AUTH_PROFILES = Object.freeze({
|
|||||||
allowedCredentialHeaders: Object.freeze([]),
|
allowedCredentialHeaders: Object.freeze([]),
|
||||||
requiredCredentialHeaders: 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>>);
|
} satisfies Readonly<Record<string, RestAuthProfile>>);
|
||||||
|
|
||||||
function isCredentialHeaderName(value: unknown): value is CredentialHeaderName {
|
function isCredentialHeaderName(value: unknown): value is CredentialHeaderName {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
import { REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION } from "./reference-feature/contracts/reference-feature-contract-contribution.ts";
|
import { REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION } from "./reference-feature/contracts/reference-feature-contract-contribution.ts";
|
||||||
import { REFERENCE_FEATURE_ID } from "./reference-feature/contracts/reference-feature-contract.ts";
|
import { REFERENCE_FEATURE_ID } from "./reference-feature/contracts/reference-feature-contract.ts";
|
||||||
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
|
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
|
||||||
|
import { TECH_LOG_STUDIO_CONTRIBUTION } from "./tech-log/contracts/tech-log-studio-contract-contribution.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* §4.8. Static contract selection SSOT.
|
* §4.8. Static contract selection SSOT.
|
||||||
@@ -17,8 +18,8 @@ import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
|
|||||||
export const INSTALLED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] =
|
export const INSTALLED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] =
|
||||||
Object.freeze(
|
Object.freeze(
|
||||||
INSTALLED_PRODUCT_FEATURE_IDS.includes(REFERENCE_FEATURE_ID)
|
INSTALLED_PRODUCT_FEATURE_IDS.includes(REFERENCE_FEATURE_ID)
|
||||||
? [REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION]
|
? [REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION, TECH_LOG_STUDIO_CONTRIBUTION]
|
||||||
: [],
|
: [TECH_LOG_STUDIO_CONTRIBUTION],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const COMPOSED_CONTRACT_CONTRIBUTIONS = composeContractContributions(
|
export const COMPOSED_CONTRACT_CONTRIBUTIONS = composeContractContributions(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"packageId": "tech-log-studio-contract",
|
"packageId": "@tech-log/studio-contract",
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"digest": "sha256:99f54f56ea0c582eafdbdf9be5653e3384bef0a1b08bff67f3147ee0292019ea",
|
"digest": "sha256:99f54f56ea0c582eafdbdf9be5653e3384bef0a1b08bff67f3147ee0292019ea",
|
||||||
"sourceRevision": "ce2e748",
|
"sourceRevision": "ce2e748",
|
||||||
|
|||||||
@@ -0,0 +1,573 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CommandEffectDescriptor,
|
||||||
|
InstalledContractContribution,
|
||||||
|
InstalledHttpContract,
|
||||||
|
RuntimeValidator,
|
||||||
|
} from "../../../contracts/external-contract-runtime.ts";
|
||||||
|
import { TECH_LOG_FEATURE_ID } from "../application/tech-log-feature-input.ts";
|
||||||
|
import canonicalSource from "./studio/canonical-source.json" with { type: "json" };
|
||||||
|
import { STUDIO_ERROR_CODES } from "../adapters/http/studio-error-mapping.ts";
|
||||||
|
|
||||||
|
function zodValidator<T>(schemaId: string, schema: z.ZodType<T>): RuntimeValidator<T> {
|
||||||
|
return Object.freeze({
|
||||||
|
schemaId,
|
||||||
|
safeParse(value: unknown) {
|
||||||
|
const result = schema.safeParse(value);
|
||||||
|
if (result.success) {
|
||||||
|
return Object.freeze({ success: true as const, data: structuredClone(result.data) });
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
success: false as const,
|
||||||
|
issues: Object.freeze(
|
||||||
|
result.error.issues.map((issue) =>
|
||||||
|
Object.freeze({
|
||||||
|
path: Object.freeze(
|
||||||
|
issue.path.map((segment): string | number =>
|
||||||
|
typeof segment === "number" ? segment : String(segment),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
code: String(issue.code),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서버 payload는 canonical 계약이 소유한다. 전송 계층은 문제 문서만 엄격히
|
||||||
|
* 검증하고 성공 payload는 통과시킨다 — generated 타입이 컴파일 시점 계약이고,
|
||||||
|
* 런타임 재검증은 계약 갱신 때마다 두 곳을 고치게 만든다.
|
||||||
|
*/
|
||||||
|
const passthrough = <T>(schemaId: string) =>
|
||||||
|
zodValidator<T>(schemaId, z.unknown() as unknown as z.ZodType<T>);
|
||||||
|
|
||||||
|
const problemSchema = z
|
||||||
|
.object({
|
||||||
|
type: z.string().min(1).max(512),
|
||||||
|
title: z.string().min(1).max(240),
|
||||||
|
status: z.int().min(400).max(599),
|
||||||
|
detail: z.string().min(1).max(5000),
|
||||||
|
code: z.enum(STUDIO_ERROR_CODES as unknown as [string, ...string[]]),
|
||||||
|
})
|
||||||
|
.loose();
|
||||||
|
|
||||||
|
const PROBLEM = zodValidator("StudioProblemDetails", problemSchema);
|
||||||
|
|
||||||
|
/** 4xx 도메인 거절은 적용되지 않았음이 확정이다. 5xx/네트워크는 불확정이다. */
|
||||||
|
const COMMAND_EFFECT: CommandEffectDescriptor<z.output<typeof problemSchema>> =
|
||||||
|
Object.freeze({
|
||||||
|
successEffect: "APPLIED_CONFIRMED" as const,
|
||||||
|
classifyProblem({ status }: Readonly<{ status: number; problem: unknown }>) {
|
||||||
|
return status >= 400 && status < 500 ? "NOT_APPLIED" : "MAYBE_APPLIED";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type PathValues = Readonly<Record<string, string>>;
|
||||||
|
type QueryEntries = readonly (readonly [string, string])[];
|
||||||
|
|
||||||
|
function safeOperation(
|
||||||
|
operationId: string,
|
||||||
|
pathTemplate: string,
|
||||||
|
responseByteLimit: number,
|
||||||
|
project: (input: never) => Readonly<{ pathValues: PathValues; queryEntries: QueryEntries }>,
|
||||||
|
): InstalledHttpContract<unknown, unknown, unknown> {
|
||||||
|
return Object.freeze({
|
||||||
|
contract: Object.freeze({
|
||||||
|
operationId,
|
||||||
|
method: "GET" as const,
|
||||||
|
pathTemplate,
|
||||||
|
inputValidator: passthrough(`${operationId}Input`),
|
||||||
|
outputValidator: passthrough(`${operationId}Output`),
|
||||||
|
problemValidator: PROBLEM,
|
||||||
|
acceptedStatuses: Object.freeze([200]),
|
||||||
|
emptyBodyStatuses: Object.freeze([]),
|
||||||
|
retrySemantics: "SAFE" as const,
|
||||||
|
requestBody: "NONE" as const,
|
||||||
|
responseBody: "REQUIRED_JSON" as const,
|
||||||
|
commandRecovery: null,
|
||||||
|
commandEffect: null,
|
||||||
|
projectRequest(input: never) {
|
||||||
|
const projected = project(input);
|
||||||
|
return Object.freeze({ ...projected, body: null });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
frontend: Object.freeze({
|
||||||
|
policyId: `${operationId}_V1`,
|
||||||
|
requestByteLimit: 0,
|
||||||
|
responseByteLimit,
|
||||||
|
totalDeadlineMs: 10_000,
|
||||||
|
retryBudget: 2 as const,
|
||||||
|
authProfileId: "TECH_LOG_STUDIO_SESSION",
|
||||||
|
diagnosticsOperation: `techLog.studio.${operationId}`,
|
||||||
|
}),
|
||||||
|
}) as InstalledHttpContract<unknown, unknown, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyedOperation(
|
||||||
|
operationId: string,
|
||||||
|
method: "POST" | "PUT" | "DELETE",
|
||||||
|
pathTemplate: string,
|
||||||
|
options: Readonly<{
|
||||||
|
acceptedStatus: number;
|
||||||
|
responseByteLimit: number;
|
||||||
|
requestByteLimit: number;
|
||||||
|
hasBody: boolean;
|
||||||
|
}>,
|
||||||
|
project: (input: never) => Readonly<{
|
||||||
|
pathValues: PathValues;
|
||||||
|
queryEntries: QueryEntries;
|
||||||
|
body: unknown;
|
||||||
|
}>,
|
||||||
|
): InstalledHttpContract<unknown, unknown, unknown> {
|
||||||
|
return Object.freeze({
|
||||||
|
contract: Object.freeze({
|
||||||
|
operationId,
|
||||||
|
method,
|
||||||
|
pathTemplate,
|
||||||
|
inputValidator: passthrough(`${operationId}Input`),
|
||||||
|
outputValidator: passthrough(`${operationId}Output`),
|
||||||
|
problemValidator: PROBLEM,
|
||||||
|
acceptedStatuses: Object.freeze([options.acceptedStatus]),
|
||||||
|
emptyBodyStatuses: Object.freeze(options.acceptedStatus === 204 ? [204] : []),
|
||||||
|
retrySemantics: "KEYED" as const,
|
||||||
|
requestBody: options.hasBody ? ("JSON" as const) : ("NONE" as const),
|
||||||
|
responseBody: options.acceptedStatus === 204 ? ("NONE" as const) : ("REQUIRED_JSON" as const),
|
||||||
|
commandRecovery: Object.freeze({
|
||||||
|
mode: "IDEMPOTENCY_REPLAY" as const,
|
||||||
|
operationIdentityField: "idempotencyKey",
|
||||||
|
}),
|
||||||
|
commandEffect: COMMAND_EFFECT,
|
||||||
|
projectRequest: project,
|
||||||
|
}),
|
||||||
|
frontend: Object.freeze({
|
||||||
|
policyId: `${operationId}_V1`,
|
||||||
|
requestByteLimit: options.requestByteLimit,
|
||||||
|
responseByteLimit: options.responseByteLimit,
|
||||||
|
totalDeadlineMs: 10_000,
|
||||||
|
// §8.3. 발신된 KEYED 명령은 자동 재시도하지 않는다.
|
||||||
|
retryBudget: 0 as const,
|
||||||
|
authProfileId: "TECH_LOG_STUDIO_SESSION",
|
||||||
|
diagnosticsOperation: `techLog.studio.${operationId}`,
|
||||||
|
}),
|
||||||
|
}) as InstalledHttpContract<unknown, unknown, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NO_PATH = Object.freeze({});
|
||||||
|
const NO_QUERY = Object.freeze([]) as QueryEntries;
|
||||||
|
|
||||||
|
function queryOf(input: Readonly<Record<string, unknown>>): QueryEntries {
|
||||||
|
const entries: (readonly [string, string])[] = [];
|
||||||
|
for (const [key, value] of Object.entries(input)) {
|
||||||
|
if (value === undefined || value === null) continue;
|
||||||
|
entries.push([key, String(value)]);
|
||||||
|
}
|
||||||
|
return Object.freeze(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Session / dashboard
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const GET_STUDIO_SESSION = safeOperation(
|
||||||
|
"getStudioSession",
|
||||||
|
"/api/v1/studio/session",
|
||||||
|
8_192,
|
||||||
|
() => Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const GET_STUDIO_DASHBOARD = safeOperation(
|
||||||
|
"getStudioDashboard",
|
||||||
|
"/api/v1/studio/dashboard",
|
||||||
|
262_144,
|
||||||
|
() => Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Documents
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const LIST_STUDIO_DOCUMENTS = safeOperation(
|
||||||
|
"listStudioDocuments",
|
||||||
|
"/api/v1/studio/documents",
|
||||||
|
262_144,
|
||||||
|
(input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{
|
||||||
|
q?: string;
|
||||||
|
kind?: string;
|
||||||
|
publicationStatus?: string;
|
||||||
|
nextAction?: string;
|
||||||
|
projectId?: string;
|
||||||
|
sort?: string;
|
||||||
|
cursor?: string;
|
||||||
|
limit?: number;
|
||||||
|
}>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: NO_PATH,
|
||||||
|
queryEntries: queryOf({
|
||||||
|
q: value.q,
|
||||||
|
kind: value.kind,
|
||||||
|
publicationStatus: value.publicationStatus,
|
||||||
|
nextAction: value.nextAction,
|
||||||
|
projectId: value.projectId,
|
||||||
|
sort: value.sort,
|
||||||
|
cursor: value.cursor,
|
||||||
|
limit: value.limit,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const CREATE_STUDIO_DOCUMENT = keyedOperation(
|
||||||
|
"createStudioDocument",
|
||||||
|
"POST",
|
||||||
|
"/api/v1/studio/documents",
|
||||||
|
{ acceptedStatus: 201, responseByteLimit: 131_072, requestByteLimit: 131_072, hasBody: true },
|
||||||
|
(input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{ document: unknown }>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: NO_PATH,
|
||||||
|
queryEntries: NO_QUERY,
|
||||||
|
body: value.document,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const GET_STUDIO_DOCUMENT = safeOperation(
|
||||||
|
"getStudioDocument",
|
||||||
|
"/api/v1/studio/documents/{documentId}",
|
||||||
|
524_288,
|
||||||
|
(input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{ documentId: string }>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: Object.freeze({ documentId: value.documentId }),
|
||||||
|
queryEntries: NO_QUERY,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const SAVE_STUDIO_DOCUMENT = keyedOperation(
|
||||||
|
"saveStudioDocument",
|
||||||
|
"PUT",
|
||||||
|
"/api/v1/studio/documents/{documentId}",
|
||||||
|
{ acceptedStatus: 200, responseByteLimit: 524_288, requestByteLimit: 524_288, hasBody: true },
|
||||||
|
(input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{
|
||||||
|
documentId: string;
|
||||||
|
expectedVersion: number;
|
||||||
|
document: unknown;
|
||||||
|
}>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: Object.freeze({ documentId: value.documentId }),
|
||||||
|
queryEntries: NO_QUERY,
|
||||||
|
body: Object.freeze({
|
||||||
|
expectedVersion: value.expectedVersion,
|
||||||
|
document: value.document,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const VALIDATE_STUDIO_DOCUMENT = keyedOperation(
|
||||||
|
"validateStudioDocument",
|
||||||
|
"POST",
|
||||||
|
"/api/v1/studio/documents/{documentId}/validate",
|
||||||
|
{ acceptedStatus: 200, responseByteLimit: 262_144, requestByteLimit: 262_144, hasBody: true },
|
||||||
|
(input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{
|
||||||
|
documentId: string;
|
||||||
|
expectedVersion: number;
|
||||||
|
}>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: Object.freeze({ documentId: value.documentId }),
|
||||||
|
queryEntries: NO_QUERY,
|
||||||
|
body: Object.freeze({ expectedVersion: value.expectedVersion }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Previews
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const GET_CURRENT_STUDIO_PREVIEW = safeOperation(
|
||||||
|
"getCurrentStudioPreview",
|
||||||
|
"/api/v1/studio/documents/{documentId}/preview",
|
||||||
|
1_048_576,
|
||||||
|
(input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{ documentId: string }>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: Object.freeze({ documentId: value.documentId }),
|
||||||
|
queryEntries: NO_QUERY,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const CREATE_STUDIO_PREVIEW = keyedOperation(
|
||||||
|
"createStudioPreview",
|
||||||
|
"POST",
|
||||||
|
"/api/v1/studio/documents/{documentId}/preview",
|
||||||
|
{ acceptedStatus: 201, responseByteLimit: 1_048_576, requestByteLimit: 1_048_576, hasBody: true },
|
||||||
|
(input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{
|
||||||
|
documentId: string;
|
||||||
|
expectedVersion: number;
|
||||||
|
validationId: string;
|
||||||
|
}>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: Object.freeze({ documentId: value.documentId }),
|
||||||
|
queryEntries: NO_QUERY,
|
||||||
|
body: Object.freeze({
|
||||||
|
expectedVersion: value.expectedVersion,
|
||||||
|
validationId: value.validationId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Publications
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const PUBLISH_STUDIO_DOCUMENT = keyedOperation(
|
||||||
|
"publishStudioDocument",
|
||||||
|
"POST",
|
||||||
|
"/api/v1/studio/documents/{documentId}/publish",
|
||||||
|
{ acceptedStatus: 200, responseByteLimit: 131_072, requestByteLimit: 131_072, hasBody: true },
|
||||||
|
(input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{
|
||||||
|
documentId: string;
|
||||||
|
expectedVersion: number;
|
||||||
|
validationId: string;
|
||||||
|
previewId: string;
|
||||||
|
acknowledgedWarningCodes: readonly string[];
|
||||||
|
}>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: Object.freeze({ documentId: value.documentId }),
|
||||||
|
queryEntries: NO_QUERY,
|
||||||
|
body: Object.freeze({
|
||||||
|
expectedVersion: value.expectedVersion,
|
||||||
|
validationId: value.validationId,
|
||||||
|
previewId: value.previewId,
|
||||||
|
acknowledgedWarningCodes: value.acknowledgedWarningCodes,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const LIST_STUDIO_PUBLICATIONS = safeOperation(
|
||||||
|
"listStudioPublications",
|
||||||
|
"/api/v1/studio/publications",
|
||||||
|
262_144,
|
||||||
|
(input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{
|
||||||
|
q?: string;
|
||||||
|
type?: string;
|
||||||
|
cursor?: string;
|
||||||
|
limit?: number;
|
||||||
|
}>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: NO_PATH,
|
||||||
|
queryEntries: queryOf({
|
||||||
|
q: value.q,
|
||||||
|
type: value.type,
|
||||||
|
cursor: value.cursor,
|
||||||
|
limit: value.limit,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const UNPUBLISH_STUDIO_PUBLICATION = keyedOperation(
|
||||||
|
"unpublishStudioPublication",
|
||||||
|
"POST",
|
||||||
|
"/api/v1/studio/publications/{publicationId}/unpublish",
|
||||||
|
{ acceptedStatus: 200, responseByteLimit: 131_072, requestByteLimit: 131_072, hasBody: true },
|
||||||
|
(input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{
|
||||||
|
publicationId: string;
|
||||||
|
expectedPublicationRevision: number;
|
||||||
|
}>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: Object.freeze({ publicationId: value.publicationId }),
|
||||||
|
queryEntries: NO_QUERY,
|
||||||
|
body: Object.freeze({
|
||||||
|
expectedPublicationRevision: value.expectedPublicationRevision,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const GET_STUDIO_PUBLICATION_SNAPSHOT = safeOperation(
|
||||||
|
"getStudioPublicationSnapshot",
|
||||||
|
"/api/v1/studio/publications/{publicationEventId}/preview",
|
||||||
|
1_048_576,
|
||||||
|
(input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{ publicationEventId: string }>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: Object.freeze({ publicationEventId: value.publicationEventId }),
|
||||||
|
queryEntries: NO_QUERY,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Catalog / assets
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const LIST_STUDIO_CATALOG = safeOperation(
|
||||||
|
"listStudioCatalog",
|
||||||
|
"/api/v1/studio/catalog",
|
||||||
|
131_072,
|
||||||
|
(input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{
|
||||||
|
type: string;
|
||||||
|
q?: string;
|
||||||
|
cursor?: string;
|
||||||
|
limit?: number;
|
||||||
|
}>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: NO_PATH,
|
||||||
|
queryEntries: queryOf({
|
||||||
|
type: value.type,
|
||||||
|
q: value.q,
|
||||||
|
cursor: value.cursor,
|
||||||
|
limit: value.limit,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const LIST_STUDIO_ASSETS = safeOperation(
|
||||||
|
"listStudioAssets",
|
||||||
|
"/api/v1/studio/assets",
|
||||||
|
262_144,
|
||||||
|
(input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{
|
||||||
|
q?: string;
|
||||||
|
kind?: string;
|
||||||
|
managementStatus?: string;
|
||||||
|
cursor?: string;
|
||||||
|
limit?: number;
|
||||||
|
}>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: NO_PATH,
|
||||||
|
queryEntries: queryOf({
|
||||||
|
q: value.q,
|
||||||
|
kind: value.kind,
|
||||||
|
managementStatus: value.managementStatus,
|
||||||
|
cursor: value.cursor,
|
||||||
|
limit: value.limit,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const GET_STUDIO_ASSET = safeOperation(
|
||||||
|
"getStudioAsset",
|
||||||
|
"/api/v1/studio/assets/{assetId}",
|
||||||
|
65_536,
|
||||||
|
(input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{ assetId: string }>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: Object.freeze({ assetId: value.assetId }),
|
||||||
|
queryEntries: NO_QUERY,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const UPDATE_STUDIO_ASSET = keyedOperation(
|
||||||
|
"updateStudioAsset",
|
||||||
|
"PUT",
|
||||||
|
"/api/v1/studio/assets/{assetId}",
|
||||||
|
{ acceptedStatus: 200, responseByteLimit: 65_536, requestByteLimit: 65_536, hasBody: true },
|
||||||
|
(input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{
|
||||||
|
assetId: string;
|
||||||
|
expectedVersion: number;
|
||||||
|
kind?: string;
|
||||||
|
altText?: string | null;
|
||||||
|
decorative?: boolean;
|
||||||
|
managementStatus?: string;
|
||||||
|
}>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: Object.freeze({ assetId: value.assetId }),
|
||||||
|
queryEntries: NO_QUERY,
|
||||||
|
body: Object.freeze({
|
||||||
|
expectedVersion: value.expectedVersion,
|
||||||
|
kind: value.kind,
|
||||||
|
altText: value.altText,
|
||||||
|
decorative: value.decorative,
|
||||||
|
managementStatus: value.managementStatus,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const DELETE_STUDIO_ASSET = keyedOperation(
|
||||||
|
"deleteStudioAsset",
|
||||||
|
"DELETE",
|
||||||
|
"/api/v1/studio/assets/{assetId}",
|
||||||
|
{ acceptedStatus: 204, responseByteLimit: 65_536, requestByteLimit: 0, hasBody: false },
|
||||||
|
(input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{ assetId: string }>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: Object.freeze({ assetId: value.assetId }),
|
||||||
|
queryEntries: NO_QUERY,
|
||||||
|
body: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Contribution assembly
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const HTTP_CONTRACTS = Object.freeze([
|
||||||
|
GET_STUDIO_SESSION,
|
||||||
|
GET_STUDIO_DASHBOARD,
|
||||||
|
LIST_STUDIO_DOCUMENTS,
|
||||||
|
CREATE_STUDIO_DOCUMENT,
|
||||||
|
GET_STUDIO_DOCUMENT,
|
||||||
|
SAVE_STUDIO_DOCUMENT,
|
||||||
|
VALIDATE_STUDIO_DOCUMENT,
|
||||||
|
GET_CURRENT_STUDIO_PREVIEW,
|
||||||
|
CREATE_STUDIO_PREVIEW,
|
||||||
|
PUBLISH_STUDIO_DOCUMENT,
|
||||||
|
LIST_STUDIO_PUBLICATIONS,
|
||||||
|
UNPUBLISH_STUDIO_PUBLICATION,
|
||||||
|
GET_STUDIO_PUBLICATION_SNAPSHOT,
|
||||||
|
LIST_STUDIO_CATALOG,
|
||||||
|
LIST_STUDIO_ASSETS,
|
||||||
|
GET_STUDIO_ASSET,
|
||||||
|
UPDATE_STUDIO_ASSET,
|
||||||
|
DELETE_STUDIO_ASSET,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const TECH_LOG_STUDIO_OPERATION_IDS = Object.freeze(
|
||||||
|
HTTP_CONTRACTS.map((entry) => entry.contract.operationId),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type TechLogStudioOperationId =
|
||||||
|
(typeof TECH_LOG_STUDIO_OPERATION_IDS)[number];
|
||||||
|
|
||||||
|
export const TECH_LOG_STUDIO_CONTRIBUTION: InstalledContractContribution =
|
||||||
|
Object.freeze({
|
||||||
|
contributionId: "tech-log-studio-http-v1",
|
||||||
|
featureId: TECH_LOG_FEATURE_ID,
|
||||||
|
source: Object.freeze({
|
||||||
|
kind: "EXTERNAL_PACKAGE" as const,
|
||||||
|
package: Object.freeze({
|
||||||
|
packageId: canonicalSource.packageId,
|
||||||
|
version: canonicalSource.version,
|
||||||
|
digest: canonicalSource.digest as `sha256:${string}`,
|
||||||
|
runtimeProtocolVersion: 1 as const,
|
||||||
|
sourceRevision: canonicalSource.sourceRevision,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
http: HTTP_CONTRACTS,
|
||||||
|
events: Object.freeze([]),
|
||||||
|
});
|
||||||
@@ -14,7 +14,7 @@ test("vendored contract matches the recorded canonical digest", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("canonical source records the pinned revision and version", () => {
|
test("canonical source records the pinned revision and version", () => {
|
||||||
assert.equal(canonicalSource.packageId, "tech-log-studio-contract");
|
assert.equal(canonicalSource.packageId, "@tech-log/studio-contract");
|
||||||
assert.equal(canonicalSource.version, "2.0.0");
|
assert.equal(canonicalSource.version, "2.0.0");
|
||||||
// revision은 생성 시점에 기록된다. canonical 저장소는 활발히 편집 중이므로
|
// revision은 생성 시점에 기록된다. canonical 저장소는 활발히 편집 중이므로
|
||||||
// 특정 값을 박아두면 계약이 그대로인데도 테스트가 깨진다. 형식만 고정한다.
|
// 특정 값을 박아두면 계약이 그대로인데도 테스트가 깨진다. 형식만 고정한다.
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "vitest";
|
||||||
|
|
||||||
|
import canonicalSource from "../../../src/features/tech-log/contracts/studio/canonical-source.json" with { type: "json" };
|
||||||
|
import {
|
||||||
|
TECH_LOG_STUDIO_CONTRIBUTION,
|
||||||
|
TECH_LOG_STUDIO_OPERATION_IDS,
|
||||||
|
} from "../../../src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts";
|
||||||
|
import { composeContractContributions } from "../../../src/contracts/external-contract-runtime.ts";
|
||||||
|
|
||||||
|
const UPLOAD = "uploadStudioAsset";
|
||||||
|
|
||||||
|
test("declares every canonical operation except the multipart upload", () => {
|
||||||
|
const expected = canonicalSource.operationIds.filter((id) => id !== UPLOAD);
|
||||||
|
assert.equal(expected.length, 18);
|
||||||
|
assert.deepEqual(
|
||||||
|
[...TECH_LOG_STUDIO_OPERATION_IDS].sort(),
|
||||||
|
[...expected].sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pins the canonical digest and revision as external package provenance", () => {
|
||||||
|
const source = TECH_LOG_STUDIO_CONTRIBUTION.source;
|
||||||
|
assert.equal(source.kind, "EXTERNAL_PACKAGE");
|
||||||
|
if (source.kind !== "EXTERNAL_PACKAGE") return;
|
||||||
|
assert.equal(source.package.packageId, canonicalSource.packageId);
|
||||||
|
assert.equal(source.package.version, canonicalSource.version);
|
||||||
|
assert.equal(source.package.digest, canonicalSource.digest);
|
||||||
|
assert.equal(source.package.sourceRevision, canonicalSource.sourceRevision);
|
||||||
|
assert.equal(source.package.runtimeProtocolVersion, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("composes without violating the platform contract runtime", () => {
|
||||||
|
const composed = composeContractContributions([TECH_LOG_STUDIO_CONTRIBUTION]);
|
||||||
|
assert.equal(composed.externalPackages.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every mutating operation replays by idempotency key and never auto-retries", () => {
|
||||||
|
for (const entry of TECH_LOG_STUDIO_CONTRIBUTION.http) {
|
||||||
|
if (entry.contract.retrySemantics !== "KEYED") continue;
|
||||||
|
assert.deepEqual(
|
||||||
|
entry.contract.commandRecovery,
|
||||||
|
{
|
||||||
|
mode: "IDEMPOTENCY_REPLAY",
|
||||||
|
operationIdentityField: "idempotencyKey",
|
||||||
|
},
|
||||||
|
`${entry.contract.operationId} recovery`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
entry.frontend.retryBudget,
|
||||||
|
0,
|
||||||
|
`${entry.contract.operationId} budget`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("path templates match the canonical /api/v1/studio prefix", () => {
|
||||||
|
for (const entry of TECH_LOG_STUDIO_CONTRIBUTION.http) {
|
||||||
|
assert.ok(
|
||||||
|
entry.contract.pathTemplate.startsWith("/api/v1/studio/"),
|
||||||
|
`${entry.contract.operationId}: ${entry.contract.pathTemplate}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
composeRuntimeSchemaCodecs,
|
composeRuntimeSchemaCodecs,
|
||||||
validateWithRuntimeSchemaRegistry,
|
validateWithRuntimeSchemaRegistry,
|
||||||
} from "../../src/contracts/schema-registry.ts";
|
} from "../../src/contracts/schema-registry.ts";
|
||||||
|
import { INSTALLED_REST_AUTH_PROFILES } from "../../src/contracts/rest-profiles.ts";
|
||||||
|
|
||||||
describe("HTTP platform schema boundary", () => {
|
describe("HTTP platform schema boundary", () => {
|
||||||
it("rejects an invalid top-level envelope", () => {
|
it("rejects an invalid top-level envelope", () => {
|
||||||
@@ -68,3 +69,20 @@ describe("runtime schema codec contribution", () => {
|
|||||||
).toThrow("duplicate runtime schema codec");
|
).toThrow("duplicate runtime schema codec");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("REST auth profile registry", () => {
|
||||||
|
it("installs the TechLog Studio session auth profile", () => {
|
||||||
|
const profile = INSTALLED_REST_AUTH_PROFILES.get(
|
||||||
|
"TECH_LOG_STUDIO_SESSION",
|
||||||
|
);
|
||||||
|
expect(profile).toBeTruthy();
|
||||||
|
expect(profile?.transport).toBe("SAME_ORIGIN_COOKIE");
|
||||||
|
expect(profile?.credentials).toBe("include");
|
||||||
|
expect([...(profile?.requiredCredentialHeaders ?? [])]).toEqual([
|
||||||
|
"x-csrf-token",
|
||||||
|
]);
|
||||||
|
expect([...(profile?.allowedCredentialHeaders ?? [])]).toEqual([
|
||||||
|
"x-csrf-token",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,9 +4,22 @@ import {
|
|||||||
loadReleaseManifest,
|
loadReleaseManifest,
|
||||||
ReleaseManifestError,
|
ReleaseManifestError,
|
||||||
} from "../../src/bootstrap/load-release-manifest.ts";
|
} from "../../src/bootstrap/load-release-manifest.ts";
|
||||||
import { computeContractSetDigest } from "../../src/contracts/contract-set-canonical.ts";
|
import {
|
||||||
|
computeContractSetDigest,
|
||||||
|
type ContractSetPackage,
|
||||||
|
} from "../../src/contracts/contract-set-canonical.ts";
|
||||||
|
import { EXPECTED_CONTRACT_SET_PACKAGES } from "../../src/features/installed-contract-contributions.ts";
|
||||||
|
|
||||||
const EMPTY_SET_DIGEST = await computeContractSetDigest([]);
|
const EMPTY_SET_DIGEST = await computeContractSetDigest([]);
|
||||||
|
/**
|
||||||
|
* TechLog's Studio contribution is always installed (Task 3), so the build's
|
||||||
|
* real expected contract set is no longer empty. A coherent manifest fixture
|
||||||
|
* must declare exactly what this build actually compiled in, or the boot-time
|
||||||
|
* `verifyContractSet` check rejects it as `CONTRACT_SET_PACKAGE_MISSING`.
|
||||||
|
*/
|
||||||
|
const EXPECTED_PACKAGES =
|
||||||
|
EXPECTED_CONTRACT_SET_PACKAGES as readonly ContractSetPackage[];
|
||||||
|
const EXPECTED_SET_DIGEST = await computeContractSetDigest(EXPECTED_PACKAGES);
|
||||||
|
|
||||||
const runtime: Parameters<typeof loadReleaseManifest>[0] = {
|
const runtime: Parameters<typeof loadReleaseManifest>[0] = {
|
||||||
build: {
|
build: {
|
||||||
@@ -49,8 +62,8 @@ const manifest = {
|
|||||||
routeChunks: { "route-home": "assets/home.js" },
|
routeChunks: { "route-home": "assets/home.js" },
|
||||||
contractSet: {
|
contractSet: {
|
||||||
setAlgorithm: "CA_CONTRACT_SET_V1",
|
setAlgorithm: "CA_CONTRACT_SET_V1",
|
||||||
setDigest: EMPTY_SET_DIGEST,
|
setDigest: EXPECTED_SET_DIGEST,
|
||||||
packages: [],
|
packages: EXPECTED_PACKAGES,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user