diff --git a/config/contracts/registry-change-evidence.json b/config/contracts/registry-change-evidence.json index 4b66667..249a2e6 100644 --- a/config/contracts/registry-change-evidence.json +++ b/config/contracts/registry-change-evidence.json @@ -280,6 +280,86 @@ "compatibilityWindow": "Existing valid envelopes continue to decode; invalid values fail closed.", "rollback": "Remove the required codec field and runtime codec dispatch together.", "owner": "frontend-platform" + }, + { + "changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_HOME:access:field-changed", + "versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.", + "migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.", + "compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.", + "rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.", + "owner": "tech-log-frontend" + }, + { + "changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_DOCUMENTS:access:field-changed", + "versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.", + "migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.", + "compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.", + "rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.", + "owner": "tech-log-frontend" + }, + { + "changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_DOCUMENT_NEW:access:field-changed", + "versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.", + "migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.", + "compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.", + "rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.", + "owner": "tech-log-frontend" + }, + { + "changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_DOCUMENT_EDIT:access:field-changed", + "versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.", + "migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.", + "compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.", + "rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.", + "owner": "tech-log-frontend" + }, + { + "changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_DOCUMENT_VALIDATION:access:field-changed", + "versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.", + "migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.", + "compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.", + "rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.", + "owner": "tech-log-frontend" + }, + { + "changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_DOCUMENT_PREVIEW:access:field-changed", + "versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.", + "migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.", + "compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.", + "rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.", + "owner": "tech-log-frontend" + }, + { + "changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_DOCUMENT_PUBLISH:access:field-changed", + "versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.", + "migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.", + "compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.", + "rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.", + "owner": "tech-log-frontend" + }, + { + "changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_PUBLICATIONS:access:field-changed", + "versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.", + "migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.", + "compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.", + "rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.", + "owner": "tech-log-frontend" + }, + { + "changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_PUBLICATION_PREVIEW:access:field-changed", + "versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.", + "migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.", + "compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.", + "rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.", + "owner": "tech-log-frontend" + }, + { + "changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_NOT_FOUND:access:field-changed", + "versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.", + "migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.", + "compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.", + "rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.", + "owner": "tech-log-frontend" } ] } diff --git a/config/runtime/development.json b/config/runtime/development.json index 9b189e5..c60aaaf 100644 --- a/config/runtime/development.json +++ b/config/runtime/development.json @@ -14,6 +14,7 @@ "OFFLINE_COMMANDS": "DEFAULT" }, "TECH_LOG_STUDIO_SOURCE": "HTTP", + "TECH_LOG_PUBLIC_SOURCE": "HTTP", "FEATURE_OVERRIDES": { "reference-feature": "DEFAULT" } diff --git a/config/runtime/local.json b/config/runtime/local.json index 8383085..38c19cb 100644 --- a/config/runtime/local.json +++ b/config/runtime/local.json @@ -14,6 +14,7 @@ "OFFLINE_COMMANDS": "DEFAULT" }, "TECH_LOG_STUDIO_SOURCE": "MOCK", + "TECH_LOG_PUBLIC_SOURCE": "MOCK", "FEATURE_OVERRIDES": { "reference-feature": "DEFAULT" } diff --git a/config/runtime/production.json b/config/runtime/production.json index 3304e2e..b3f734d 100644 --- a/config/runtime/production.json +++ b/config/runtime/production.json @@ -15,6 +15,7 @@ "OFFLINE_COMMANDS": "DEFAULT" }, "TECH_LOG_STUDIO_SOURCE": "HTTP", + "TECH_LOG_PUBLIC_SOURCE": "MOCK", "FEATURE_OVERRIDES": { "reference-feature": "DEFAULT" } diff --git a/config/runtime/staging.json b/config/runtime/staging.json index fc376d8..63e3111 100644 --- a/config/runtime/staging.json +++ b/config/runtime/staging.json @@ -15,6 +15,7 @@ "OFFLINE_COMMANDS": "DEFAULT" }, "TECH_LOG_STUDIO_SOURCE": "HTTP", + "TECH_LOG_PUBLIC_SOURCE": "HTTP", "FEATURE_OVERRIDES": { "reference-feature": "DEFAULT" } diff --git a/public/release-manifest.json b/public/release-manifest.json index d4dc30e..8f2e61e 100644 --- a/public/release-manifest.json +++ b/public/release-manifest.json @@ -38,14 +38,14 @@ }, "contractSet": { "setAlgorithm": "CA_CONTRACT_SET_V1", - "setDigest": "sha256:261ac63030fcbcef8bd0b7087d29120996a9f580f455739dad245141860c1fe5", + "setDigest": "sha256:5a67c3ce96f6be3a9314040f3039ca32a81f9eaf4402c88a745e235d183f23df", "packages": [ { "packageId": "@tech-log/studio-contract", "version": "3.0.0", "digest": "sha256:6cae9924403d0761f401643a022980b8e04183eea0d890c143c9fbbbbc7431e4", "runtimeProtocolVersion": 1, - "sourceRevision": "b20d7a2" + "sourceRevision": "55a9599" } ] } diff --git a/scripts/generate-tech-log-contract.ts b/scripts/generate-tech-log-contract.ts index b75d2d4..02ce433 100644 --- a/scripts/generate-tech-log-contract.ts +++ b/scripts/generate-tech-log-contract.ts @@ -1,5 +1,5 @@ /** - * canonical studio-v1.yaml을 vendor하고 타입을 생성한다. + * canonical 계약(studio-v1, public-v1)을 vendor하고 타입을 생성한다. * * 생성기는 저장소 의존성에 넣지 않는다. `openapi-typescript`는 TypeScript 5의 * classic compiler API를 요구하는데 이 저장소는 TypeScript 7.0.2를 고정하고 @@ -12,15 +12,45 @@ */ import { createHash } from "node:crypto"; import { execFileSync } from "node:child_process"; -import { readFileSync, writeFileSync } from "node:fs"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; import { argv, env, exit } from "node:process"; const CANONICAL_ROOT = env.TECH_LOG_DESIGN_PACKAGE ?? "/home/donghyeon/workspace/tech-log-design-package"; -const CANONICAL_YAML = `${CANONICAL_ROOT}/contracts/openapi/studio-v1.yaml`; -const VENDOR_YAML = "src/features/tech-log/contracts/studio/studio-api.openapi.yaml"; -const GENERATED = "src/features/tech-log/contracts/studio/generated.ts"; -const SOURCE_RECORD = "src/features/tech-log/contracts/studio/canonical-source.json"; + +/** + * 계약은 둘이고 서로 독립이다. Studio는 인증된 작성 표면이고, Public은 인증 + * 없는 조회 표면이다. 각자 자기 canonical yaml에서 나오고 자기 digest를 들고 + * 다니므로, 한쪽이 갱신돼도 다른 쪽 drift 게이트는 조용하다. + */ +type ContractTarget = Readonly<{ + name: string; + packageId: string; + canonicalYaml: string; + vendorYaml: string; + generated: string; + sourceRecord: string; +}>; + +const CONTRACTS: readonly ContractTarget[] = Object.freeze([ + Object.freeze({ + name: "studio", + packageId: "@tech-log/studio-contract", + canonicalYaml: `${CANONICAL_ROOT}/contracts/openapi/studio-v1.yaml`, + vendorYaml: "src/features/tech-log/contracts/studio/studio-api.openapi.yaml", + generated: "src/features/tech-log/contracts/studio/generated.ts", + sourceRecord: "src/features/tech-log/contracts/studio/canonical-source.json", + }), + Object.freeze({ + name: "public", + packageId: "@tech-log/public-contract", + canonicalYaml: `${CANONICAL_ROOT}/contracts/openapi/public-v1.yaml`, + vendorYaml: "src/features/tech-log/contracts/public/public-api.openapi.yaml", + generated: "src/features/tech-log/contracts/public/generated.ts", + sourceRecord: "src/features/tech-log/contracts/public/canonical-source.json", + }), +]); const OPENAPI_TYPESCRIPT = "openapi-typescript@7.9.1"; const GENERATOR_TYPESCRIPT = "typescript@5.9.3"; @@ -56,71 +86,84 @@ function fail(problems: readonly string[]): never { } if (check) { - const vendored = readFileSync(VENDOR_YAML, "utf8"); - const generated = readFileSync(GENERATED, "utf8"); - const record = JSON.parse(readFileSync(SOURCE_RECORD, "utf8")) as CanonicalRecord; const problems: string[] = []; + const summaries: string[] = []; - if (digestOf(readFileSync(VENDOR_YAML)) !== record.digest) { - problems.push(`${VENDOR_YAML} does not hash to the recorded digest`); - } - const vendoredOperations = operationIdsOf(vendored); - if (vendoredOperations.join(" ") !== [...record.operationIds].join(" ")) { - problems.push(`${SOURCE_RECORD} operationIds differ from ${VENDOR_YAML}`); - } - if (specVersionOf(vendored) !== record.version) { - problems.push(`${SOURCE_RECORD} version differs from ${VENDOR_YAML}`); - } - // 생성물은 operationId로 키가 매겨진 `operations` 인터페이스를 노출한다. - for (const operationId of record.operationIds) { - if (!new RegExp(`^\\s{4}${operationId}:`, "mu").test(generated)) { - problems.push(`${GENERATED} is missing operation ${operationId}`); + for (const target of CONTRACTS) { + const vendored = readFileSync(target.vendorYaml, "utf8"); + const generated = readFileSync(target.generated, "utf8"); + const record = JSON.parse(readFileSync(target.sourceRecord, "utf8")) as CanonicalRecord; + + if (digestOf(readFileSync(target.vendorYaml)) !== record.digest) { + problems.push(`${target.vendorYaml} does not hash to the recorded digest`); } + if (operationIdsOf(vendored).join(" ") !== [...record.operationIds].join(" ")) { + problems.push(`${target.sourceRecord} operationIds differ from ${target.vendorYaml}`); + } + if (specVersionOf(vendored) !== record.version) { + problems.push(`${target.sourceRecord} version differs from ${target.vendorYaml}`); + } + if (record.packageId !== target.packageId) { + problems.push(`${target.sourceRecord} packageId is not ${target.packageId}`); + } + // 생성물은 operationId로 키가 매겨진 `operations` 인터페이스를 노출한다. + for (const operationId of record.operationIds) { + if (!new RegExp(`^\\s{4}${operationId}:`, "mu").test(generated)) { + problems.push(`${target.generated} is missing operation ${operationId}`); + } + } + summaries.push( + `${record.packageId}@${record.version} (${record.sourceRevision}), ${record.operationIds.length} operations`, + ); } + if (problems.length > 0) fail(problems); - console.log( - `tech-log contract is in sync: ${record.packageId}@${record.version} (${record.sourceRevision}), ${record.operationIds.length} operations.`, - ); + console.log(`tech-log contracts are in sync:\n- ${summaries.join("\n- ")}`); exit(0); } -const canonicalBytes = readFileSync(CANONICAL_YAML); -const canonicalText = canonicalBytes.toString("utf8"); +const sourceRevision = execFileSync( + "git", + ["-C", CANONICAL_ROOT, "rev-parse", "--short=7", "HEAD"], + { encoding: "utf8" }, +).trim(); -const record: CanonicalRecord = { - packageId: "@tech-log/studio-contract", - version: specVersionOf(canonicalText), - digest: digestOf(canonicalBytes), - sourceRevision: execFileSync( - "git", - ["-C", CANONICAL_ROOT, "rev-parse", "--short=7", "HEAD"], - { encoding: "utf8" }, - ).trim(), - operationIds: operationIdsOf(canonicalText), -}; +for (const target of CONTRACTS) { + const canonicalBytes = readFileSync(target.canonicalYaml); + const canonicalText = canonicalBytes.toString("utf8"); -// 격리 실행. 저장소의 node_modules와 lockfile은 그대로다. -const generated = execFileSync( - "corepack", - [ - "pnpm", - "dlx", - "--package", - GENERATOR_TYPESCRIPT, - "--package", - OPENAPI_TYPESCRIPT, - "openapi-typescript", - CANONICAL_YAML, - ], - { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }, -); + const record: CanonicalRecord = { + packageId: target.packageId, + version: specVersionOf(canonicalText), + digest: digestOf(canonicalBytes), + sourceRevision, + operationIds: operationIdsOf(canonicalText), + }; -writeFileSync(VENDOR_YAML, canonicalText); -writeFileSync(GENERATED, generated); -writeFileSync(SOURCE_RECORD, `${JSON.stringify(record, null, 2)}\n`); -console.log( - `Generated from ${record.packageId}@${record.version} (${record.sourceRevision}), ${record.operationIds.length} operations.`, -); + // 격리 실행. 저장소의 node_modules와 lockfile은 그대로다. + const generated = execFileSync( + "corepack", + [ + "pnpm", + "dlx", + "--package", + GENERATOR_TYPESCRIPT, + "--package", + OPENAPI_TYPESCRIPT, + "openapi-typescript", + target.canonicalYaml, + ], + { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }, + ); + + mkdirSync(dirname(target.vendorYaml), { recursive: true }); + writeFileSync(target.vendorYaml, canonicalText); + writeFileSync(target.generated, generated); + writeFileSync(target.sourceRecord, `${JSON.stringify(record, null, 2)}\n`); + console.log( + `Generated from ${record.packageId}@${record.version} (${record.sourceRevision}), ${record.operationIds.length} operations.`, + ); +} // 재생성은 매번 package digest를 바꾼다. `pnpm dev`가 그대로 서빙하는 // `public/release-manifest.json`은 build가 컴파일한 contract set을 그대로 diff --git a/src/bootstrap/runtime-adapters.ts b/src/bootstrap/runtime-adapters.ts index 2b145d3..9a29bc8 100644 --- a/src/bootstrap/runtime-adapters.ts +++ b/src/bootstrap/runtime-adapters.ts @@ -599,6 +599,7 @@ export async function createRuntimeAdapters( const featureInputs = createInstalledFeatureInputs({ contractOperations, studioSource: config.TECH_LOG_STUDIO_SOURCE, + publicSource: config.TECH_LOG_PUBLIC_SOURCE, apiBaseUrl: config.API_BASE_URL, requestTimeoutMs: config.REQUEST_TIMEOUT_MS, csrf: techLogCsrf, diff --git a/src/bootstrap/runtime-config-schema.ts b/src/bootstrap/runtime-config-schema.ts index 8539439..791d8bb 100644 --- a/src/bootstrap/runtime-config-schema.ts +++ b/src/bootstrap/runtime-config-schema.ts @@ -56,6 +56,12 @@ export type RuntimeConfig = Readonly<{ * MOCK. */ TECH_LOG_STUDIO_SOURCE: "MOCK" | "HTTP"; + /** + * §3.5-adjacent runtime switch: which public-read adapter this build talks + * to. Independent of the Studio switch — the two surfaces are separate + * services. A V1 document predates the key and normalizes to MOCK. + */ + TECH_LOG_PUBLIC_SOURCE: "MOCK" | "HTTP"; /** Present only while a V1 document is still accepted. */ LEGACY_API_CONTRACT_VERSION?: string; }>; @@ -143,6 +149,10 @@ export function validateRuntimeConfig(value: unknown): RuntimeConfigValidation { TECH_LOG_STUDIO_SOURCE: isV2 ? (parsed as RuntimeConfigV2).TECH_LOG_STUDIO_SOURCE : "MOCK", + // Same treatment for the public-read switch. + TECH_LOG_PUBLIC_SOURCE: isV2 + ? (parsed as RuntimeConfigV2).TECH_LOG_PUBLIC_SOURCE + : "MOCK", ...(isV2 ? {} : { diff --git a/src/contracts/env.ts b/src/contracts/env.ts index 5f7cb27..c48be4b 100644 --- a/src/contracts/env.ts +++ b/src/contracts/env.ts @@ -52,6 +52,11 @@ export const ENV_REGISTRY = Object.freeze({ // TechLog Studio gateway adapter selection. Defaults to MOCK while the // backend does not exist yet. TECH_LOG_STUDIO_SOURCE: runtime("public", false, "MOCK"), + // TechLog public-read adapter selection. Separate from the Studio switch on + // purpose: the two surfaces are different services on different schedules, + // and the combination that matters right now — an authoring backend that is + // live while the public read API is not — is unreachable with one flag. + TECH_LOG_PUBLIC_SOURCE: runtime("public", false, "MOCK"), // §3.5: build-time narrowing of the product manifest. A feature left out // here is not imported by any registry and never reaches the bundle. VITE_PRODUCT_FEATURES: build("compile-time", false, null), diff --git a/src/contracts/release-artifacts.ts b/src/contracts/release-artifacts.ts index d58e188..0fb687b 100644 --- a/src/contracts/release-artifacts.ts +++ b/src/contracts/release-artifacts.ts @@ -159,6 +159,10 @@ export const runtimeConfigV2ArtifactSchema = z // TechLog Studio adapter selection. Backend is not live yet, so the // default is the in-memory mock; a document may opt a build into HTTP. TECH_LOG_STUDIO_SOURCE: z.enum(["MOCK", "HTTP"]).default("MOCK"), + // TechLog public-read adapter selection, defaulted the same way and for + // the same reason. Held apart from the Studio switch so one surface can + // move to HTTP without dragging the other with it. + TECH_LOG_PUBLIC_SOURCE: z.enum(["MOCK", "HTTP"]).default("MOCK"), }) .strict() .superRefine(runtimeConfigArtifactInvariants); diff --git a/src/features/installed-feature-adapters.ts b/src/features/installed-feature-adapters.ts index 56c4982..316066b 100644 --- a/src/features/installed-feature-adapters.ts +++ b/src/features/installed-feature-adapters.ts @@ -23,6 +23,7 @@ export function createInstalledFeatureInputs( context: Parameters[0] & Readonly<{ studioSource: "MOCK" | "HTTP"; + publicSource: "MOCK" | "HTTP"; apiBaseUrl: string; requestTimeoutMs: number; csrf: CsrfTokenProvider; diff --git a/src/features/tech-log/adapters/create-tech-log-feature-input.ts b/src/features/tech-log/adapters/create-tech-log-feature-input.ts index fae4760..1be8f03 100644 --- a/src/features/tech-log/adapters/create-tech-log-feature-input.ts +++ b/src/features/tech-log/adapters/create-tech-log-feature-input.ts @@ -27,6 +27,7 @@ import { publicContentQueries } from "./static/public-query.ts"; */ export type TechLogInstallContext = Readonly<{ studioSource: "MOCK" | "HTTP"; + publicSource: "MOCK" | "HTTP"; contractOperations: StudioOperationExecutor; apiBaseUrl: string; requestTimeoutMs: number; diff --git a/src/features/tech-log/contracts/public/canonical-source.json b/src/features/tech-log/contracts/public/canonical-source.json new file mode 100644 index 0000000..60b05a7 --- /dev/null +++ b/src/features/tech-log/contracts/public/canonical-source.json @@ -0,0 +1,26 @@ +{ + "packageId": "@tech-log/public-contract", + "version": "2.0.0", + "digest": "sha256:8ac71425b38658f34641102b4c2e6e21288c811efebdb92c0a46fb9d4790e23e", + "sourceRevision": "55a9599", + "operationIds": [ + "getPublicSite", + "getPublicHome", + "exploreKnowledge", + "exploreQuestions", + "listPublicTopics", + "getPublicTopic", + "getPublicCase", + "getPublicReference", + "getPublicQuestion", + "listPublicProjects", + "getPublicProject", + "listPublicProjectDecisions", + "listPublicProjectRecords", + "listPublicProjectActivities", + "listPublicReleases", + "getPublicRelease", + "getPublicProfile", + "searchPublicResources" + ] +} diff --git a/src/features/tech-log/contracts/public/generated.ts b/src/features/tech-log/contracts/public/generated.ts new file mode 100644 index 0000000..55911b1 --- /dev/null +++ b/src/features/tech-log/contracts/public/generated.ts @@ -0,0 +1,1589 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/site": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getPublicSite"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/home": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getPublicHome"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/explore/knowledge": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Frontend의 `/explore`, `/explore/cases`, `/explore/references` 화면이 + * 호출한다. 화면 route별로 별도 endpoint를 만들지 않고 `type` 필터로 구분한다. + * + * ```text + * /explore/cases → type=CASE + * /explore/references → type=REFERENCE + * /explore → type 생략 + * ``` + * */ + get: operations["exploreKnowledge"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/explore/questions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Frontend의 `/explore/questions` 화면이 호출한다. + * + * 여기의 공개 상태는 `OPEN`/`RESOLVED` 축약 표현이다. Backend Inquiry의 + * `INVESTIGATING`/`PAUSED`는 `OPEN`으로 표현되며 `ARCHIVED`는 공개되지 않는다. + * Domain lifecycle 자체는 축소되지 않는다. + * */ + get: operations["exploreQuestions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/topics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listPublicTopics"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/topics/{topicSlug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getPublicTopic"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/cases/{slug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getPublicCase"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/references/{slug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getPublicReference"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/questions/{slug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getPublicQuestion"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/projects": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listPublicProjects"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/projects/{slug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getPublicProject"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/projects/{slug}/decisions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listPublicProjectDecisions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/projects/{slug}/records": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listPublicProjectRecords"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/projects/{slug}/activities": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listPublicProjectActivities"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/releases": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listPublicReleases"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/releases/{version}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getPublicRelease"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/profile": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getPublicProfile"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["searchPublicResources"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + FieldError: { + field: string; + code: string; + message: string; + }; + PageMetadata: { + number: number; + size: number; + /** Format: int64 */ + totalElements: number; + totalPages: number; + hasPrevious: boolean; + hasNext: boolean; + }; + TopicSummary: { + name: string; + slug: string; + }; + TagSummary: { + name: string; + slug: string; + }; + ProjectSummary: { + name: string; + slug: string; + path: string; + }; + AssetReference: { + /** Format: uuid */ + assetId: string; + url: string; + altText?: string; + width?: number; + height?: number; + contentType?: string; + }; + RelatedEntry: { + /** @enum {string} */ + type: "CASE" | "REFERENCE" | "QUESTION" | "PROJECT" | "PROJECT_DECISION" | "RELEASE"; + title: string; + summary?: string; + path: string; + }; + ContactLink: { + type: string; + label: string; + /** Format: uri */ + url: string; + }; + SiteResponse: { + brand: { + title: string; + identityStatement: string; + }; + operator: { + displayName: string; + shortIdentity?: string; + avatar?: components["schemas"]["AssetReference"]; + profilePath: string; + }; + contacts: components["schemas"]["ContactLink"][]; + }; + CurrentWorkFocus: { + projectName: string; + projectPath: string; + purpose: string; + /** @enum {string} */ + phase: "RESEARCH" | "DESIGN" | "IMPLEMENTATION" | "VERIFICATION" | "MAINTENANCE" | "PAUSED" | "COMPLETED"; + currentObjective: string; + nextStep?: string; + /** Format: date-time */ + updatedAt: string; + }; + OpenQuestionFocus: { + question: string; + questionPath: string; + summary: string; + knownFacts: string[]; + unresolvedPoints: string[]; + nextVerification: string; + /** Format: date-time */ + updatedAt: string; + }; + RecentDecisionFocus: { + statement: string; + decisionPath: string; + rationale: string; + consequences: string[]; + /** Format: date-time */ + decidedAt: string; + }; + LatestEntry: { + /** @enum {string} */ + entryType: "CASE" | "REFERENCE" | "PROJECT_ACTIVITY" | "RELEASE"; + title: string; + summary: string; + path: string; + primaryTopic?: components["schemas"]["TopicSummary"]; + primaryProject?: components["schemas"]["ProjectSummary"]; + /** Format: date-time */ + publishedAt: string; + }; + HomeResponse: { + focus: { + /** @enum {string} */ + defaultType: "CURRENT_WORK" | "OPEN_QUESTION" | "RECENT_DECISION"; + currentWork?: components["schemas"]["CurrentWorkFocus"]; + openQuestion?: components["schemas"]["OpenQuestionFocus"]; + recentDecision?: components["schemas"]["RecentDecisionFocus"]; + }; + latestEntries: components["schemas"]["LatestEntry"][]; + }; + KnowledgeListItem: { + /** @enum {string} */ + type: "CASE" | "REFERENCE"; + title: string; + path: string; + primarySummary: string; + secondarySummary?: string; + primaryTopic?: components["schemas"]["TopicSummary"]; + primaryProject?: components["schemas"]["ProjectSummary"]; + /** Format: date-time */ + publishedAt: string; + /** Format: date-time */ + lastVerifiedAt?: string; + freshnessStatus?: string; + }; + KnowledgePage: { + items: components["schemas"]["KnowledgeListItem"][]; + page: components["schemas"]["PageMetadata"]; + }; + QuestionListItem: { + question: string; + path: string; + /** @enum {string} */ + status: "OPEN" | "INVESTIGATING" | "PAUSED" | "RESOLVED"; + summary: string; + currentUnderstanding?: string; + nextVerification?: string; + primaryProject?: components["schemas"]["ProjectSummary"]; + /** Format: date-time */ + updatedAt: string; + }; + QuestionPage: { + items: components["schemas"]["QuestionListItem"][]; + page: components["schemas"]["PageMetadata"]; + }; + TopicListItem: { + name: string; + slug: string; + description: string; + recordCount?: number; + }; + TopicListResponse: { + items: components["schemas"]["TopicListItem"][]; + }; + TopicDetailResponse: { + topic: { + name: string; + slug: string; + description: string; + scope?: string; + }; + featuredReference?: components["schemas"]["RelatedEntry"]; + featuredCases: components["schemas"]["RelatedEntry"][]; + activeQuestions: components["schemas"]["RelatedEntry"][]; + relatedProjects: components["schemas"]["RelatedEntry"][]; + latestRecords: components["schemas"]["LatestEntry"][]; + }; + CaseDetailResponse: { + canonicalPath: string; + /** @default true */ + indexable: boolean; + case: { + title: string; + problemSummary: string; + conclusionSummary: string; + environmentSummary?: string[]; + content: string; + /** @enum {string} */ + contentFormat: "MARKDOWN"; + contentFormatVersion: number; + primaryTopic?: components["schemas"]["TopicSummary"]; + tags: components["schemas"]["TagSummary"][]; + primaryProject?: components["schemas"]["ProjectSummary"]; + coverAsset?: components["schemas"]["AssetReference"]; + /** Format: date-time */ + publishedAt: string; + /** Format: date-time */ + updatedAt: string; + /** Format: date-time */ + lastVerifiedAt?: string; + }; + relations: { + originQuestion?: components["schemas"]["RelatedEntry"]; + projectDecisions: components["schemas"]["RelatedEntry"][]; + derivedReferences: components["schemas"]["RelatedEntry"][]; + relatedCases: components["schemas"]["RelatedEntry"][]; + }; + }; + ReferenceDetailResponse: { + canonicalPath: string; + /** @default true */ + indexable: boolean; + reference: { + title: string; + scopeSummary: string; + appliesTo: string[]; + excludedScope: string[]; + /** @enum {string} */ + freshnessStatus: "CURRENT" | "REVIEW_DUE" | "HISTORICAL"; + content: string; + /** @enum {string} */ + contentFormat: "MARKDOWN"; + contentFormatVersion: number; + primaryTopic?: components["schemas"]["TopicSummary"]; + tags: components["schemas"]["TagSummary"][]; + primaryProject?: components["schemas"]["ProjectSummary"]; + coverAsset?: components["schemas"]["AssetReference"]; + /** Format: date-time */ + publishedAt: string; + /** Format: date-time */ + updatedAt: string; + /** Format: date-time */ + lastVerifiedAt?: string; + }; + relations: { + supportingCases: components["schemas"]["RelatedEntry"][]; + relatedDecisions: components["schemas"]["RelatedEntry"][]; + relatedReferences: components["schemas"]["RelatedEntry"][]; + }; + }; + QuestionPointGroup: { + facts: string[]; + assumptions: string[]; + unknowns: string[]; + constraints: string[]; + }; + QuestionUpdatePublic: { + type: string; + title: string; + bodyMarkdown: string; + /** Format: date-time */ + occurredAt: string; + }; + QuestionDetailResponse: { + canonicalPath: string; + indexable: boolean; + question: { + question: string; + summary: string; + context: string; + importance: string; + /** @enum {string} */ + status: "OPEN" | "INVESTIGATING" | "PAUSED" | "RESOLVED"; + nextVerification?: string; + points: components["schemas"]["QuestionPointGroup"]; + updates: components["schemas"]["QuestionUpdatePublic"][]; + resolution?: { + type?: string; + summary?: string; + /** Format: date-time */ + resolvedAt?: string; + }; + /** Format: date-time */ + openedAt: string; + /** Format: date-time */ + updatedAt: string; + }; + relations: { + primaryProject?: components["schemas"]["RelatedEntry"]; + resultCase?: components["schemas"]["RelatedEntry"]; + producedDecision?: components["schemas"]["RelatedEntry"]; + derivedReferences: components["schemas"]["RelatedEntry"][]; + }; + }; + ProjectListItem: { + name: string; + slug: string; + path: string; + oneLinePurpose: string; + phase: string; + currentObjective?: string; + nextStep?: string; + /** Format: date-time */ + updatedAt: string; + }; + ProjectListResponse: { + items: components["schemas"]["ProjectListItem"][]; + }; + ProjectDetailResponse: { + canonicalPath: string; + /** @default true */ + indexable: boolean; + project: { + name: string; + slug: string; + oneLinePurpose: string; + purpose: string; + boundary: string; + phase: string; + currentObjective?: string; + nextStep?: string; + systemOverviewMarkdown?: string; + technologies?: string[]; + /** Format: date-time */ + updatedAt: string; + }; + featuredDecision?: components["schemas"]["RelatedEntry"]; + activeQuestion?: components["schemas"]["RelatedEntry"]; + selectedRecords: components["schemas"]["RelatedEntry"][]; + }; + ProjectDecisionItem: { + /** Format: uuid */ + id: string; + statement: string; + status: string; + rationaleSummary?: string; + /** Format: date-time */ + decidedAt: string; + sourceQuestion?: components["schemas"]["RelatedEntry"]; + sourceCase?: components["schemas"]["RelatedEntry"]; + }; + ProjectDecisionPage: { + items: components["schemas"]["ProjectDecisionItem"][]; + page: components["schemas"]["PageMetadata"]; + }; + ProjectRecordPage: { + items: components["schemas"]["RelatedEntry"][]; + page: components["schemas"]["PageMetadata"]; + }; + ProjectActivityItem: { + type: string; + title: string; + summary?: string; + /** Format: date-time */ + occurredAt: string; + relatedPath?: string; + }; + ProjectActivityPage: { + items: components["schemas"]["ProjectActivityItem"][]; + page: components["schemas"]["PageMetadata"]; + }; + ReleaseListItem: { + version: string; + title: string; + summary: string; + /** Format: date */ + releasedOn: string; + changeTypes: string[]; + path: string; + }; + ReleaseListResponse: { + items: components["schemas"]["ReleaseListItem"][]; + }; + ReleaseDetailResponse: { + version: string; + title: string; + summary: string; + /** Format: date */ + releasedOn: string; + changeTypes: string[]; + reasonMarkdown?: string; + changesMarkdown: string; + userImpactMarkdown?: string; + implementationImpactMarkdown?: string; + verificationMarkdown: string; + knownLimitationsMarkdown?: string; + relatedRecords: components["schemas"]["RelatedEntry"][]; + }; + ProfileResponse: { + position: { + headline: string; + description: string; + }; + workingModel: { + name: string; + description: string; + }[]; + territories: { + name: string; + currentQuestion?: string; + topicPath?: string; + }[]; + selectedEvidence: components["schemas"]["RelatedEntry"][]; + trajectory: { + title: string; + description: string; + }[]; + contacts: components["schemas"]["ContactLink"][]; + }; + SearchResultItem: { + contentType: string; + title: string; + path: string; + snippet: string; + matchedFields: string[]; + primaryTopic?: components["schemas"]["TopicSummary"]; + primaryProject?: components["schemas"]["ProjectSummary"]; + /** Format: date-time */ + publishedAt?: string; + /** Format: date-time */ + updatedAt?: string; + }; + SearchResultPage: { + query: string; + items: components["schemas"]["SearchResultItem"][]; + page: components["schemas"]["PageMetadata"]; + }; + ResponseMeta: { + requestId: string; + traceId: string; + correlationId?: string | null; + /** @description offset 페이지네이션 정보는 각 페이지 payload 의 `page` 필드가 소유한다. 이 필드는 백엔드 템플릿의 ResponseMeta record 가 직렬화하는 자리이며 공개 조회에서는 항상 null 이다. */ + page?: { + [key: string]: unknown; + } | null; + }; + ValidationErrorDetails: { + fieldErrors: components["schemas"]["FieldError"][]; + }; + ApiError: { + /** + * @description `INTERNAL_ERROR` 는 이 기능이 아니라 스켈레톤의 공통 처리기가 내는 코드다. 계약이 그것까지 열거해야 500 응답이 계약을 벗어나지 않는다. + * @enum {string} + */ + code: "PUBLIC_REQUEST_INVALID" | "PUBLIC_RESOURCE_NOT_FOUND" | "INTERNAL_ERROR"; + /** @enum {string} */ + category: "VALIDATION" | "AUTH" | "AUTHZ" | "NOT_FOUND" | "CONFLICT" | "RATE_LIMIT" | "TRANSIENT_DEPENDENCY" | "PERMANENT_DEPENDENCY" | "DATA_INTEGRITY" | "INTERNAL"; + message: string; + retryable: boolean; + details?: components["schemas"]["ValidationErrorDetails"] | null; + }; + ErrorEnvelope: { + /** @constant */ + success: false; + error: components["schemas"]["ApiError"]; + meta: components["schemas"]["ResponseMeta"]; + }; + SiteResponseEnvelope: { + /** @constant */ + success: true; + data: components["schemas"]["SiteResponse"]; + meta: components["schemas"]["ResponseMeta"]; + }; + HomeResponseEnvelope: { + /** @constant */ + success: true; + data: components["schemas"]["HomeResponse"]; + meta: components["schemas"]["ResponseMeta"]; + }; + KnowledgePageEnvelope: { + /** @constant */ + success: true; + data: components["schemas"]["KnowledgePage"]; + meta: components["schemas"]["ResponseMeta"]; + }; + QuestionPageEnvelope: { + /** @constant */ + success: true; + data: components["schemas"]["QuestionPage"]; + meta: components["schemas"]["ResponseMeta"]; + }; + TopicListResponseEnvelope: { + /** @constant */ + success: true; + data: components["schemas"]["TopicListResponse"]; + meta: components["schemas"]["ResponseMeta"]; + }; + TopicDetailResponseEnvelope: { + /** @constant */ + success: true; + data: components["schemas"]["TopicDetailResponse"]; + meta: components["schemas"]["ResponseMeta"]; + }; + CaseDetailResponseEnvelope: { + /** @constant */ + success: true; + data: components["schemas"]["CaseDetailResponse"]; + meta: components["schemas"]["ResponseMeta"]; + }; + ReferenceDetailResponseEnvelope: { + /** @constant */ + success: true; + data: components["schemas"]["ReferenceDetailResponse"]; + meta: components["schemas"]["ResponseMeta"]; + }; + QuestionDetailResponseEnvelope: { + /** @constant */ + success: true; + data: components["schemas"]["QuestionDetailResponse"]; + meta: components["schemas"]["ResponseMeta"]; + }; + ProjectListResponseEnvelope: { + /** @constant */ + success: true; + data: components["schemas"]["ProjectListResponse"]; + meta: components["schemas"]["ResponseMeta"]; + }; + ProjectDetailResponseEnvelope: { + /** @constant */ + success: true; + data: components["schemas"]["ProjectDetailResponse"]; + meta: components["schemas"]["ResponseMeta"]; + }; + ProjectDecisionPageEnvelope: { + /** @constant */ + success: true; + data: components["schemas"]["ProjectDecisionPage"]; + meta: components["schemas"]["ResponseMeta"]; + }; + ProjectRecordPageEnvelope: { + /** @constant */ + success: true; + data: components["schemas"]["ProjectRecordPage"]; + meta: components["schemas"]["ResponseMeta"]; + }; + ProjectActivityPageEnvelope: { + /** @constant */ + success: true; + data: components["schemas"]["ProjectActivityPage"]; + meta: components["schemas"]["ResponseMeta"]; + }; + ReleaseListResponseEnvelope: { + /** @constant */ + success: true; + data: components["schemas"]["ReleaseListResponse"]; + meta: components["schemas"]["ResponseMeta"]; + }; + ReleaseDetailResponseEnvelope: { + /** @constant */ + success: true; + data: components["schemas"]["ReleaseDetailResponse"]; + meta: components["schemas"]["ResponseMeta"]; + }; + ProfileResponseEnvelope: { + /** @constant */ + success: true; + data: components["schemas"]["ProfileResponse"]; + meta: components["schemas"]["ResponseMeta"]; + }; + SearchResultPageEnvelope: { + /** @constant */ + success: true; + data: components["schemas"]["SearchResultPage"]; + meta: components["schemas"]["ResponseMeta"]; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + getPublicSite: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SiteResponseEnvelope"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + getPublicHome: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HomeResponseEnvelope"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + exploreKnowledge: { + parameters: { + query?: { + type?: "CASE" | "REFERENCE"; + topic?: string; + project?: string; + tag?: string; + year?: number; + sort?: "PUBLISHED_DESC" | "UPDATED_DESC" | "VERIFIED_DESC"; + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["KnowledgePageEnvelope"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + exploreQuestions: { + parameters: { + query?: { + status?: "OPEN" | "INVESTIGATING" | "PAUSED" | "RESOLVED"; + topic?: string; + project?: string; + tag?: string; + sort?: "UPDATED_DESC" | "OPENED_DESC" | "RESOLVED_DESC"; + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["QuestionPageEnvelope"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + listPublicTopics: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TopicListResponseEnvelope"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + getPublicTopic: { + parameters: { + query?: never; + header?: never; + path: { + topicSlug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TopicDetailResponseEnvelope"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + getPublicCase: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CaseDetailResponseEnvelope"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + getPublicReference: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReferenceDetailResponseEnvelope"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + getPublicQuestion: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["QuestionDetailResponseEnvelope"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + listPublicProjects: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProjectListResponseEnvelope"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + getPublicProject: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProjectDetailResponseEnvelope"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + listPublicProjectDecisions: { + parameters: { + query?: { + status?: string; + page?: number; + size?: number; + }; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProjectDecisionPageEnvelope"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + listPublicProjectRecords: { + parameters: { + query?: { + type?: "CASE" | "REFERENCE" | "QUESTION"; + relation?: "PRIMARY" | "RELATED"; + page?: number; + size?: number; + }; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProjectRecordPageEnvelope"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + listPublicProjectActivities: { + parameters: { + query?: { + page?: number; + size?: number; + }; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProjectActivityPageEnvelope"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + listPublicReleases: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReleaseListResponseEnvelope"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + getPublicRelease: { + parameters: { + query?: never; + header?: never; + path: { + version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReleaseDetailResponseEnvelope"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + getPublicProfile: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProfileResponseEnvelope"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + searchPublicResources: { + parameters: { + query: { + q: string; + type?: "CASE" | "REFERENCE" | "QUESTION" | "PROJECT" | "RELEASE"; + topic?: string; + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SearchResultPageEnvelope"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; +} diff --git a/src/features/tech-log/contracts/public/public-api.openapi.yaml b/src/features/tech-log/contracts/public/public-api.openapi.yaml new file mode 100644 index 0000000..9e19c10 --- /dev/null +++ b/src/features/tech-log/contracts/public/public-api.openapi.yaml @@ -0,0 +1,2104 @@ +openapi: 3.1.0 +info: + title: Tech Log Public API + version: 2.0.0 + description: | + Tech Log 공개 조회 계약이다. 인증이 필요하지 않다. + + ## 응답 봉투 (ADR-006) + + 모든 응답은 `{success, data|error, meta}` 봉투다. v1.0.0 은 bare payload + + RFC 7807 ProblemDetails 였고, ADR-006 이 그 둘을 함께 쓰지 않기로 정했으므로 + 구현 착수와 함께 studio-v1.yaml 과 같은 방식으로 변환했다. + + 오류 본문은 `application/problem+json` 이 아니라 `application/json` + + `ErrorEnvelope` 다. `error.code` 가 클라이언트가 분기하는 값이며 HTTP status 는 + 그 코드의 부수 정보다. + + ## UI route와 API route는 같을 필요가 없다 + + Frontend의 `/explore/:kind`는 화면 route다. 이 계약의 endpoint와 URL 구조를 + 억지로 일치시키지 않는다. + + | Frontend 화면 route | 호출하는 operation | + |---|---| + | `/explore` | `exploreKnowledge` + `exploreQuestions` | + | `/explore/cases` | `exploreKnowledge` (`type=CASE`) | + | `/explore/references` | `exploreKnowledge` (`type=REFERENCE`) | + | `/explore/questions` | `exploreQuestions` | + + ## 이 계약이 반환하지 않는 것 + + - Working Copy: Studio 계약(`studio-v1.yaml`)만 반환한다. + - Validation / Preview artifact: 인증된 Studio 계약에만 존재한다. + - Publication Event / Snapshot 이력: Studio 계약이 소유한다. + + Public이 노출하는 것은 현재 ACTIVE Public Projection뿐이다. 과거 게시 시점 + 화면은 Studio의 immutable Publication Snapshot이 책임진다. + + ## Asset + + 본문의 Evidence Figure는 managed `assetKey`로 해석된다. object storage URL이 + 콘텐츠 원문에 들어가지 않으며, 응답은 검증된 delivery path만 제공한다. + SVG도 URL 기반 ``로 렌더링하고 원문을 inline하지 않는다. +servers: +- url: /api/v1/public +tags: +- name: Site +- name: Home +- name: Explore +- name: Topic +- name: Case +- name: Reference +- name: Question +- name: Project +- name: Release +- name: Profile +- name: Search +paths: + /site: + get: + operationId: getPublicSite + tags: + - Site + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SiteResponseEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /home: + get: + operationId: getPublicHome + tags: + - Home + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/HomeResponseEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /explore/knowledge: + get: + operationId: exploreKnowledge + description: | + Frontend의 `/explore`, `/explore/cases`, `/explore/references` 화면이 + 호출한다. 화면 route별로 별도 endpoint를 만들지 않고 `type` 필터로 구분한다. + + ```text + /explore/cases → type=CASE + /explore/references → type=REFERENCE + /explore → type 생략 + ``` + tags: + - Explore + parameters: + - name: type + in: query + schema: + type: string + enum: + - CASE + - REFERENCE + - name: topic + in: query + schema: + type: string + - name: project + in: query + schema: + type: string + - name: tag + in: query + schema: + type: string + - name: year + in: query + schema: + type: integer + minimum: 2000 + - name: sort + in: query + schema: + type: string + enum: + - PUBLISHED_DESC + - UPDATED_DESC + - VERIFIED_DESC + default: PUBLISHED_DESC + - &id001 + name: page + in: query + schema: + type: integer + minimum: 1 + default: 1 + - &id002 + name: size + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/KnowledgePageEnvelope' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /explore/questions: + get: + operationId: exploreQuestions + description: | + Frontend의 `/explore/questions` 화면이 호출한다. + + 여기의 공개 상태는 `OPEN`/`RESOLVED` 축약 표현이다. Backend Inquiry의 + `INVESTIGATING`/`PAUSED`는 `OPEN`으로 표현되며 `ARCHIVED`는 공개되지 않는다. + Domain lifecycle 자체는 축소되지 않는다. + tags: + - Explore + parameters: + - name: status + in: query + schema: + type: string + enum: + - OPEN + - INVESTIGATING + - PAUSED + - RESOLVED + - name: topic + in: query + schema: + type: string + - name: project + in: query + schema: + type: string + - name: tag + in: query + schema: + type: string + - name: sort + in: query + schema: + type: string + enum: + - UPDATED_DESC + - OPENED_DESC + - RESOLVED_DESC + default: UPDATED_DESC + - *id001 + - *id002 + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/QuestionPageEnvelope' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /topics: + get: + operationId: listPublicTopics + tags: + - Topic + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/TopicListResponseEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /topics/{topicSlug}: + get: + operationId: getPublicTopic + tags: + - Topic + parameters: + - name: topicSlug + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/TopicDetailResponseEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /cases/{slug}: + get: + operationId: getPublicCase + tags: + - Case + parameters: + - name: slug + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/CaseDetailResponseEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /references/{slug}: + get: + operationId: getPublicReference + tags: + - Reference + parameters: + - name: slug + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ReferenceDetailResponseEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /questions/{slug}: + get: + operationId: getPublicQuestion + tags: + - Question + parameters: + - name: slug + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/QuestionDetailResponseEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /projects: + get: + operationId: listPublicProjects + tags: + - Project + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectListResponseEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /projects/{slug}: + get: + operationId: getPublicProject + tags: + - Project + parameters: + - name: slug + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectDetailResponseEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /projects/{slug}/decisions: + get: + operationId: listPublicProjectDecisions + tags: + - Project + parameters: + - name: slug + in: path + required: true + schema: + type: string + - name: status + in: query + schema: + type: string + - *id001 + - *id002 + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectDecisionPageEnvelope' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /projects/{slug}/records: + get: + operationId: listPublicProjectRecords + tags: + - Project + parameters: + - name: slug + in: path + required: true + schema: + type: string + - name: type + in: query + schema: + type: string + enum: + - CASE + - REFERENCE + - QUESTION + - name: relation + in: query + schema: + type: string + enum: + - PRIMARY + - RELATED + - *id001 + - *id002 + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectRecordPageEnvelope' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /projects/{slug}/activities: + get: + operationId: listPublicProjectActivities + tags: + - Project + parameters: + - name: slug + in: path + required: true + schema: + type: string + - *id001 + - *id002 + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectActivityPageEnvelope' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /releases: + get: + operationId: listPublicReleases + tags: + - Release + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ReleaseListResponseEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /releases/{version}: + get: + operationId: getPublicRelease + tags: + - Release + parameters: + - name: version + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ReleaseDetailResponseEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /profile: + get: + operationId: getPublicProfile + tags: + - Profile + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ProfileResponseEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /search: + get: + operationId: searchPublicResources + tags: + - Search + parameters: + - name: q + in: query + required: true + schema: + type: string + minLength: 1 + maxLength: 100 + - name: type + in: query + schema: + type: string + enum: + - CASE + - REFERENCE + - QUESTION + - PROJECT + - RELEASE + - name: topic + in: query + schema: + type: string + - *id001 + - *id002 + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SearchResultPageEnvelope' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' +components: + schemas: + FieldError: + type: object + required: + - field + - code + - message + properties: + field: + type: string + code: + type: string + message: + type: string + PageMetadata: + type: object + required: + - number + - size + - totalElements + - totalPages + - hasPrevious + - hasNext + properties: + number: + type: integer + minimum: 1 + size: + type: integer + minimum: 1 + maximum: 100 + totalElements: + type: integer + format: int64 + minimum: 0 + totalPages: + type: integer + minimum: 0 + hasPrevious: + type: boolean + hasNext: + type: boolean + TopicSummary: + type: object + required: + - name + - slug + properties: + name: + type: string + slug: + type: string + TagSummary: + type: object + required: + - name + - slug + properties: + name: + type: string + slug: + type: string + ProjectSummary: + type: object + required: + - name + - slug + - path + properties: + name: + type: string + slug: + type: string + path: + type: string + AssetReference: + type: object + required: + - assetId + - url + properties: + assetId: &id004 + type: string + format: uuid + url: + type: string + altText: + type: string + width: + type: integer + height: + type: integer + contentType: + type: string + RelatedEntry: + type: object + required: + - type + - title + - path + properties: + type: + type: string + enum: + - CASE + - REFERENCE + - QUESTION + - PROJECT + - PROJECT_DECISION + - RELEASE + title: + type: string + summary: + type: string + path: + type: string + ContactLink: + type: object + required: + - type + - label + - url + properties: + type: + type: string + label: + type: string + url: + type: string + format: uri + SiteResponse: + type: object + required: + - brand + - operator + - contacts + properties: + brand: + type: object + required: + - title + - identityStatement + properties: + title: + type: string + identityStatement: + type: string + operator: + type: object + required: + - displayName + - profilePath + properties: + displayName: + type: string + shortIdentity: + type: string + avatar: + $ref: '#/components/schemas/AssetReference' + profilePath: + type: string + contacts: + type: array + items: + $ref: '#/components/schemas/ContactLink' + CurrentWorkFocus: + type: object + required: + - projectName + - projectPath + - purpose + - phase + - currentObjective + - updatedAt + properties: + projectName: + type: string + projectPath: + type: string + purpose: + type: string + phase: + type: string + enum: + - RESEARCH + - DESIGN + - IMPLEMENTATION + - VERIFICATION + - MAINTENANCE + - PAUSED + - COMPLETED + currentObjective: + type: string + nextStep: + type: string + updatedAt: &id003 + type: string + format: date-time + OpenQuestionFocus: + type: object + required: + - question + - questionPath + - summary + - knownFacts + - unresolvedPoints + - nextVerification + - updatedAt + properties: + question: + type: string + questionPath: + type: string + summary: + type: string + knownFacts: + type: array + items: + type: string + unresolvedPoints: + type: array + items: + type: string + nextVerification: + type: string + updatedAt: *id003 + RecentDecisionFocus: + type: object + required: + - statement + - decisionPath + - rationale + - consequences + - decidedAt + properties: + statement: + type: string + decisionPath: + type: string + rationale: + type: string + consequences: + type: array + items: + type: string + decidedAt: *id003 + LatestEntry: + type: object + required: + - entryType + - title + - summary + - path + - publishedAt + properties: + entryType: + type: string + enum: + - CASE + - REFERENCE + - PROJECT_ACTIVITY + - RELEASE + title: + type: string + summary: + type: string + path: + type: string + primaryTopic: + $ref: '#/components/schemas/TopicSummary' + primaryProject: + $ref: '#/components/schemas/ProjectSummary' + publishedAt: *id003 + HomeResponse: + type: object + required: + - focus + - latestEntries + properties: + focus: + type: object + required: + - defaultType + properties: + defaultType: + type: string + enum: + - CURRENT_WORK + - OPEN_QUESTION + - RECENT_DECISION + currentWork: + $ref: '#/components/schemas/CurrentWorkFocus' + openQuestion: + $ref: '#/components/schemas/OpenQuestionFocus' + recentDecision: + $ref: '#/components/schemas/RecentDecisionFocus' + latestEntries: + type: array + maxItems: 6 + items: + $ref: '#/components/schemas/LatestEntry' + KnowledgeListItem: + type: object + required: + - type + - title + - path + - primarySummary + - publishedAt + properties: + type: + type: string + enum: + - CASE + - REFERENCE + title: + type: string + path: + type: string + primarySummary: + type: string + secondarySummary: + type: string + primaryTopic: + $ref: '#/components/schemas/TopicSummary' + primaryProject: + $ref: '#/components/schemas/ProjectSummary' + publishedAt: *id003 + lastVerifiedAt: *id003 + freshnessStatus: + type: string + KnowledgePage: + type: object + required: + - items + - page + properties: + items: + type: array + items: + $ref: '#/components/schemas/KnowledgeListItem' + page: + $ref: '#/components/schemas/PageMetadata' + QuestionListItem: + type: object + required: + - question + - path + - status + - summary + - updatedAt + properties: + question: + type: string + path: + type: string + status: + type: string + enum: + - OPEN + - INVESTIGATING + - PAUSED + - RESOLVED + summary: + type: string + currentUnderstanding: + type: string + nextVerification: + type: string + primaryProject: + $ref: '#/components/schemas/ProjectSummary' + updatedAt: *id003 + QuestionPage: + type: object + required: + - items + - page + properties: + items: + type: array + items: + $ref: '#/components/schemas/QuestionListItem' + page: + $ref: '#/components/schemas/PageMetadata' + TopicListItem: + type: object + required: + - name + - slug + - description + properties: + name: + type: string + slug: + type: string + description: + type: string + recordCount: + type: integer + TopicListResponse: + type: object + required: + - items + properties: + items: + type: array + items: + $ref: '#/components/schemas/TopicListItem' + TopicDetailResponse: + type: object + required: + - topic + - featuredCases + - activeQuestions + - relatedProjects + - latestRecords + properties: + topic: + type: object + required: + - name + - slug + - description + properties: + name: + type: string + slug: + type: string + description: + type: string + scope: + type: string + featuredReference: + $ref: '#/components/schemas/RelatedEntry' + featuredCases: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + activeQuestions: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + relatedProjects: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + latestRecords: + type: array + items: + $ref: '#/components/schemas/LatestEntry' + CaseDetailResponse: + type: object + required: + - canonicalPath + - case + - relations + properties: + canonicalPath: + type: string + indexable: + type: boolean + default: true + case: + type: object + required: + - title + - problemSummary + - conclusionSummary + - content + - contentFormat + - contentFormatVersion + - tags + - publishedAt + - updatedAt + properties: + title: + type: string + problemSummary: + type: string + conclusionSummary: + type: string + environmentSummary: + type: array + items: + type: string + content: + type: string + contentFormat: + type: string + enum: + - MARKDOWN + contentFormatVersion: + type: integer + primaryTopic: + $ref: '#/components/schemas/TopicSummary' + tags: + type: array + items: + $ref: '#/components/schemas/TagSummary' + primaryProject: + $ref: '#/components/schemas/ProjectSummary' + coverAsset: + $ref: '#/components/schemas/AssetReference' + publishedAt: *id003 + updatedAt: *id003 + lastVerifiedAt: *id003 + relations: + type: object + required: + - projectDecisions + - derivedReferences + - relatedCases + properties: + originQuestion: + $ref: '#/components/schemas/RelatedEntry' + projectDecisions: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + derivedReferences: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + relatedCases: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + ReferenceDetailResponse: + type: object + required: + - canonicalPath + - reference + - relations + properties: + canonicalPath: + type: string + indexable: + type: boolean + default: true + reference: + type: object + required: + - title + - scopeSummary + - appliesTo + - excludedScope + - freshnessStatus + - content + - contentFormat + - contentFormatVersion + - tags + - publishedAt + - updatedAt + properties: + title: + type: string + scopeSummary: + type: string + appliesTo: + type: array + items: + type: string + excludedScope: + type: array + items: + type: string + freshnessStatus: + type: string + enum: + - CURRENT + - REVIEW_DUE + - HISTORICAL + content: + type: string + contentFormat: + type: string + enum: + - MARKDOWN + contentFormatVersion: + type: integer + primaryTopic: + $ref: '#/components/schemas/TopicSummary' + tags: + type: array + items: + $ref: '#/components/schemas/TagSummary' + primaryProject: + $ref: '#/components/schemas/ProjectSummary' + coverAsset: + $ref: '#/components/schemas/AssetReference' + publishedAt: *id003 + updatedAt: *id003 + lastVerifiedAt: *id003 + relations: + type: object + required: + - supportingCases + - relatedDecisions + - relatedReferences + properties: + supportingCases: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + relatedDecisions: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + relatedReferences: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + QuestionPointGroup: + type: object + required: + - facts + - assumptions + - unknowns + - constraints + properties: + facts: + type: array + items: + type: string + assumptions: + type: array + items: + type: string + unknowns: + type: array + items: + type: string + constraints: + type: array + items: + type: string + QuestionUpdatePublic: + type: object + required: + - type + - title + - bodyMarkdown + - occurredAt + properties: + type: + type: string + title: + type: string + bodyMarkdown: + type: string + occurredAt: *id003 + QuestionDetailResponse: + type: object + required: + - canonicalPath + - indexable + - question + - relations + properties: + canonicalPath: + type: string + indexable: + type: boolean + question: + type: object + required: + - question + - summary + - context + - importance + - status + - points + - updates + - openedAt + - updatedAt + properties: + question: + type: string + summary: + type: string + context: + type: string + importance: + type: string + status: + type: string + enum: + - OPEN + - INVESTIGATING + - PAUSED + - RESOLVED + nextVerification: + type: string + points: + $ref: '#/components/schemas/QuestionPointGroup' + updates: + type: array + items: + $ref: '#/components/schemas/QuestionUpdatePublic' + resolution: + type: object + properties: + type: + type: string + summary: + type: string + resolvedAt: *id003 + openedAt: *id003 + updatedAt: *id003 + relations: + type: object + required: + - derivedReferences + properties: + primaryProject: + $ref: '#/components/schemas/RelatedEntry' + resultCase: + $ref: '#/components/schemas/RelatedEntry' + producedDecision: + $ref: '#/components/schemas/RelatedEntry' + derivedReferences: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + ProjectListItem: + type: object + required: + - name + - slug + - path + - oneLinePurpose + - phase + - updatedAt + properties: + name: + type: string + slug: + type: string + path: + type: string + oneLinePurpose: + type: string + phase: + type: string + currentObjective: + type: string + nextStep: + type: string + updatedAt: *id003 + ProjectListResponse: + type: object + required: + - items + properties: + items: + type: array + items: + $ref: '#/components/schemas/ProjectListItem' + ProjectDetailResponse: + type: object + required: + - canonicalPath + - project + - selectedRecords + properties: + canonicalPath: + type: string + indexable: + type: boolean + default: true + project: + type: object + required: + - name + - slug + - purpose + - boundary + - phase + - oneLinePurpose + - updatedAt + properties: + name: + type: string + slug: + type: string + oneLinePurpose: + type: string + purpose: + type: string + boundary: + type: string + phase: + type: string + currentObjective: + type: string + nextStep: + type: string + systemOverviewMarkdown: + type: string + technologies: + type: array + items: + type: string + updatedAt: *id003 + featuredDecision: + $ref: '#/components/schemas/RelatedEntry' + activeQuestion: + $ref: '#/components/schemas/RelatedEntry' + selectedRecords: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + ProjectDecisionItem: + type: object + required: + - id + - statement + - status + - decidedAt + properties: + id: *id004 + statement: + type: string + status: + type: string + rationaleSummary: + type: string + decidedAt: *id003 + sourceQuestion: + $ref: '#/components/schemas/RelatedEntry' + sourceCase: + $ref: '#/components/schemas/RelatedEntry' + ProjectDecisionPage: + type: object + required: + - items + - page + properties: + items: + type: array + items: + $ref: '#/components/schemas/ProjectDecisionItem' + page: + $ref: '#/components/schemas/PageMetadata' + ProjectRecordPage: + type: object + required: + - items + - page + properties: + items: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + page: + $ref: '#/components/schemas/PageMetadata' + ProjectActivityItem: + type: object + required: + - type + - title + - occurredAt + properties: + type: + type: string + title: + type: string + summary: + type: string + occurredAt: *id003 + relatedPath: + type: string + ProjectActivityPage: + type: object + required: + - items + - page + properties: + items: + type: array + items: + $ref: '#/components/schemas/ProjectActivityItem' + page: + $ref: '#/components/schemas/PageMetadata' + ReleaseListItem: + type: object + required: + - version + - title + - summary + - releasedOn + - changeTypes + - path + properties: + version: + type: string + title: + type: string + summary: + type: string + releasedOn: &id005 + type: string + format: date + changeTypes: + type: array + items: + type: string + path: + type: string + ReleaseListResponse: + type: object + required: + - items + properties: + items: + type: array + items: + $ref: '#/components/schemas/ReleaseListItem' + ReleaseDetailResponse: + type: object + required: + - version + - title + - summary + - releasedOn + - changeTypes + - changesMarkdown + - verificationMarkdown + - relatedRecords + properties: + version: + type: string + title: + type: string + summary: + type: string + releasedOn: *id005 + changeTypes: + type: array + items: + type: string + reasonMarkdown: + type: string + changesMarkdown: + type: string + userImpactMarkdown: + type: string + implementationImpactMarkdown: + type: string + verificationMarkdown: + type: string + knownLimitationsMarkdown: + type: string + relatedRecords: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + ProfileResponse: + type: object + required: + - position + - workingModel + - territories + - selectedEvidence + - trajectory + - contacts + properties: + position: + type: object + required: + - headline + - description + properties: + headline: + type: string + description: + type: string + workingModel: + type: array + items: + type: object + required: + - name + - description + properties: + name: + type: string + description: + type: string + territories: + type: array + items: + type: object + required: + - name + properties: + name: + type: string + currentQuestion: + type: string + topicPath: + type: string + selectedEvidence: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + trajectory: + type: array + items: + type: object + required: + - title + - description + properties: + title: + type: string + description: + type: string + contacts: + type: array + items: + $ref: '#/components/schemas/ContactLink' + SearchResultItem: + type: object + required: + - contentType + - title + - path + - snippet + - matchedFields + properties: + contentType: + type: string + title: + type: string + path: + type: string + snippet: + type: string + matchedFields: + type: array + items: + type: string + primaryTopic: + $ref: '#/components/schemas/TopicSummary' + primaryProject: + $ref: '#/components/schemas/ProjectSummary' + publishedAt: *id003 + updatedAt: *id003 + SearchResultPage: + type: object + required: + - query + - items + - page + properties: + query: + type: string + items: + type: array + items: + $ref: '#/components/schemas/SearchResultItem' + page: + $ref: '#/components/schemas/PageMetadata' + ResponseMeta: + type: object + additionalProperties: false + required: + - requestId + - traceId + properties: + requestId: + type: string + minLength: 1 + maxLength: 200 + traceId: + type: string + minLength: 1 + maxLength: 200 + correlationId: + type: + - string + - 'null' + maxLength: 200 + page: + type: + - object + - 'null' + additionalProperties: true + description: offset 페이지네이션 정보는 각 페이지 payload 의 `page` 필드가 소유한다. 이 필드는 백엔드 템플릿의 ResponseMeta record 가 직렬화하는 자리이며 공개 조회에서는 항상 null 이다. + ValidationErrorDetails: + type: object + additionalProperties: false + required: + - fieldErrors + properties: + fieldErrors: + type: array + maxItems: 200 + items: + $ref: '#/components/schemas/FieldError' + ApiError: + type: object + additionalProperties: false + required: + - code + - category + - message + - retryable + properties: + code: + type: string + enum: + - PUBLIC_REQUEST_INVALID + - PUBLIC_RESOURCE_NOT_FOUND + - INTERNAL_ERROR + description: '`INTERNAL_ERROR` 는 이 기능이 아니라 스켈레톤의 공통 처리기가 내는 코드다. 계약이 그것까지 열거해야 500 응답이 계약을 벗어나지 않는다.' + category: + type: string + enum: + - VALIDATION + - AUTH + - AUTHZ + - NOT_FOUND + - CONFLICT + - RATE_LIMIT + - TRANSIENT_DEPENDENCY + - PERMANENT_DEPENDENCY + - DATA_INTEGRITY + - INTERNAL + message: + type: string + minLength: 1 + maxLength: 5000 + retryable: + type: boolean + details: + oneOf: + - $ref: '#/components/schemas/ValidationErrorDetails' + - type: 'null' + ErrorEnvelope: + type: object + additionalProperties: false + required: + - success + - error + - meta + properties: + success: + type: boolean + const: false + error: + $ref: '#/components/schemas/ApiError' + meta: + $ref: '#/components/schemas/ResponseMeta' + SiteResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/SiteResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + HomeResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/HomeResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + KnowledgePageEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/KnowledgePage' + meta: + $ref: '#/components/schemas/ResponseMeta' + QuestionPageEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/QuestionPage' + meta: + $ref: '#/components/schemas/ResponseMeta' + TopicListResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/TopicListResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + TopicDetailResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/TopicDetailResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + CaseDetailResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/CaseDetailResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + ReferenceDetailResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/ReferenceDetailResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + QuestionDetailResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/QuestionDetailResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + ProjectListResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/ProjectListResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + ProjectDetailResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/ProjectDetailResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + ProjectDecisionPageEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/ProjectDecisionPage' + meta: + $ref: '#/components/schemas/ResponseMeta' + ProjectRecordPageEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/ProjectRecordPage' + meta: + $ref: '#/components/schemas/ResponseMeta' + ProjectActivityPageEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/ProjectActivityPage' + meta: + $ref: '#/components/schemas/ResponseMeta' + ReleaseListResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/ReleaseListResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + ReleaseDetailResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/ReleaseDetailResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + ProfileResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/ProfileResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + SearchResultPageEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/SearchResultPage' + meta: + $ref: '#/components/schemas/ResponseMeta' diff --git a/src/features/tech-log/contracts/studio/canonical-source.json b/src/features/tech-log/contracts/studio/canonical-source.json index 1645536..fc599cb 100644 --- a/src/features/tech-log/contracts/studio/canonical-source.json +++ b/src/features/tech-log/contracts/studio/canonical-source.json @@ -2,7 +2,7 @@ "packageId": "@tech-log/studio-contract", "version": "3.0.0", "digest": "sha256:6cae9924403d0761f401643a022980b8e04183eea0d890c143c9fbbbbc7431e4", - "sourceRevision": "b20d7a2", + "sourceRevision": "55a9599", "operationIds": [ "getStudioSession", "getStudioDashboard",