diff --git a/scripts/generate-tech-log-contract.ts b/scripts/generate-tech-log-contract.ts index a518c49..e67ff29 100644 --- a/scripts/generate-tech-log-contract.ts +++ b/scripts/generate-tech-log-contract.ts @@ -88,7 +88,7 @@ const canonicalBytes = readFileSync(CANONICAL_YAML); const canonicalText = canonicalBytes.toString("utf8"); const record: CanonicalRecord = { - packageId: "tech-log-studio-contract", + packageId: "@tech-log/studio-contract", version: specVersionOf(canonicalText), digest: digestOf(canonicalBytes), sourceRevision: execFileSync( diff --git a/src/contracts/rest-profiles.ts b/src/contracts/rest-profiles.ts index 317f3b0..a20eb85 100644 --- a/src/contracts/rest-profiles.ts +++ b/src/contracts/rest-profiles.ts @@ -70,6 +70,19 @@ export const REST_AUTH_PROFILES = Object.freeze({ 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>); function isCredentialHeaderName(value: unknown): value is CredentialHeaderName { diff --git a/src/features/installed-contract-contributions.ts b/src/features/installed-contract-contributions.ts index 7f1f329..c77949e 100644 --- a/src/features/installed-contract-contributions.ts +++ b/src/features/installed-contract-contributions.ts @@ -6,6 +6,7 @@ import { 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 { 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. @@ -17,8 +18,8 @@ import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts"; export const INSTALLED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] = Object.freeze( 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( diff --git a/src/features/tech-log/contracts/studio/canonical-source.json b/src/features/tech-log/contracts/studio/canonical-source.json index feda2b6..a0a93d7 100644 --- a/src/features/tech-log/contracts/studio/canonical-source.json +++ b/src/features/tech-log/contracts/studio/canonical-source.json @@ -1,5 +1,5 @@ { - "packageId": "tech-log-studio-contract", + "packageId": "@tech-log/studio-contract", "version": "2.0.0", "digest": "sha256:99f54f56ea0c582eafdbdf9be5653e3384bef0a1b08bff67f3147ee0292019ea", "sourceRevision": "ce2e748", diff --git a/src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts b/src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts new file mode 100644 index 0000000..1fe194f --- /dev/null +++ b/src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts @@ -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(schemaId: string, schema: z.ZodType): RuntimeValidator { + 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 = (schemaId: string) => + zodValidator(schemaId, z.unknown() as unknown as z.ZodType); + +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> = + 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>; +type QueryEntries = readonly (readonly [string, string])[]; + +function safeOperation( + operationId: string, + pathTemplate: string, + responseByteLimit: number, + project: (input: never) => Readonly<{ pathValues: PathValues; queryEntries: QueryEntries }>, +): InstalledHttpContract { + 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; +} + +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 { + 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; +} + +const NO_PATH = Object.freeze({}); +const NO_QUERY = Object.freeze([]) as QueryEntries; + +function queryOf(input: Readonly>): 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([]), + }); diff --git a/tests/features/tech-log/contract-generation.test.ts b/tests/features/tech-log/contract-generation.test.ts index de4e0eb..af59cdf 100644 --- a/tests/features/tech-log/contract-generation.test.ts +++ b/tests/features/tech-log/contract-generation.test.ts @@ -14,7 +14,7 @@ test("vendored contract matches the recorded canonical digest", () => { }); 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"); // revision은 생성 시점에 기록된다. canonical 저장소는 활발히 편집 중이므로 // 특정 값을 박아두면 계약이 그대로인데도 테스트가 깨진다. 형식만 고정한다. diff --git a/tests/features/tech-log/studio-contract-contribution.test.ts b/tests/features/tech-log/studio-contract-contribution.test.ts new file mode 100644 index 0000000..f921859 --- /dev/null +++ b/tests/features/tech-log/studio-contract-contribution.test.ts @@ -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}`, + ); + } +}); diff --git a/tests/runtime-schema/http-schema.test.ts b/tests/runtime-schema/http-schema.test.ts index 60c68bc..9c66ea5 100644 --- a/tests/runtime-schema/http-schema.test.ts +++ b/tests/runtime-schema/http-schema.test.ts @@ -9,6 +9,7 @@ import { composeRuntimeSchemaCodecs, validateWithRuntimeSchemaRegistry, } from "../../src/contracts/schema-registry.ts"; +import { INSTALLED_REST_AUTH_PROFILES } from "../../src/contracts/rest-profiles.ts"; describe("HTTP platform schema boundary", () => { it("rejects an invalid top-level envelope", () => { @@ -68,3 +69,20 @@ describe("runtime schema codec contribution", () => { ).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", + ]); + }); +}); diff --git a/tests/runtime-schema/release-manifest.test.ts b/tests/runtime-schema/release-manifest.test.ts index 549f2f5..56dbc71 100644 --- a/tests/runtime-schema/release-manifest.test.ts +++ b/tests/runtime-schema/release-manifest.test.ts @@ -4,9 +4,22 @@ import { loadReleaseManifest, ReleaseManifestError, } 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([]); +/** + * 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[0] = { build: { @@ -49,8 +62,8 @@ const manifest = { routeChunks: { "route-home": "assets/home.js" }, contractSet: { setAlgorithm: "CA_CONTRACT_SET_V1", - setDigest: EMPTY_SET_DIGEST, - packages: [], + setDigest: EXPECTED_SET_DIGEST, + packages: EXPECTED_PACKAGES, }, };