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:
DongHyeonka
2026-08-18 01:10:04 +09:00
co-authored by Claude Opus 5
parent a6fc536d8a
commit 66c047cec8
9 changed files with 690 additions and 8 deletions
+1 -1
View File
@@ -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(
+13
View File
@@ -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}`,
);
}
});
+18
View File
@@ -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",
]);
});
});
+16 -3
View File
@@ -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,
}, },
}; };