feat: give the public surface an HTTP adapter, and a switch to reach it

The public read port had one implementation and no way to add another. This is
the second one: the 18 operations of the public contract, mapped to the nine
methods the screens call.

The contract and the screens disagree about shape, and translating here is what
keeps the presentation components untouched. The server speaks in what it
stores — timestamps, one markdown body, relations grouped by why they relate.
The screens were built against a catalog that spoke in what a page renders —
formatted labels, titled sections, one flat relation list whose group name is
the reason. Neither is wrong. Where the contract has no counterpart the value is
left empty and the gap is named where it happens rather than guessed at: a
Case's verification line, a decision's consequences, a question's options.

Sections are split from markdown here rather than through the Studio parser.
That parser produces the canonical render-block union the editor needs — inline
marks, evidence directives, tables — which is a richer tree than RecordSection
can hold, so reusing it would mean flattening away exactly the blocks that made
it worth using.

A 404 is unwrapped, not thrown. A slug that is not published is an answer the
port already has a shape for, and throwing would put a terminal-error surface on
a page whose real state is "this does not exist".

`listRecords` is one method over two endpoints, because the contract pages and
filters knowledge separately from questions. Only the unfiltered call fans out:
asking for one kind must not pay for the other.

The operations declare the ANONYMOUS auth profile, which forbids credentials
outright. That is the point — a later change that starts sending the session
cookie on a public read fails the profile check instead of quietly making a
cache-friendly surface user-specific.
This commit is contained in:
DongHyeonka
2026-08-20 17:36:18 +09:00
parent 4566f2d7a8
commit 24c01aedf2
7 changed files with 857 additions and 4 deletions
+8 -1
View File
@@ -38,8 +38,15 @@
},
"contractSet": {
"setAlgorithm": "CA_CONTRACT_SET_V1",
"setDigest": "sha256:5a67c3ce96f6be3a9314040f3039ca32a81f9eaf4402c88a745e235d183f23df",
"setDigest": "sha256:8ae48a17a30f07a0672fbc2c46ea9ebe66637bd16f6914079b3bf31b8009d12f",
"packages": [
{
"packageId": "@tech-log/public-contract",
"version": "2.0.0",
"digest": "sha256:8ac71425b38658f34641102b4c2e6e21288c811efebdb92c0a46fb9d4790e23e",
"runtimeProtocolVersion": 1,
"sourceRevision": "55a9599"
},
{
"packageId": "@tech-log/studio-contract",
"version": "3.0.0",
@@ -6,6 +6,7 @@ import {
import { REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION } from "./reference-feature/contracts/reference-feature-contract-contribution.ts";
import { REFERENCE_FEATURE_ID } from "./reference-feature/contracts/reference-feature-contract.ts";
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
import { TECH_LOG_PUBLIC_CONTRIBUTION } from "./tech-log/contracts/tech-log-public-contract-contribution.ts";
import { TECH_LOG_STUDIO_CONTRIBUTION } from "./tech-log/contracts/tech-log-studio-contract-contribution.ts";
/**
@@ -18,8 +19,12 @@ import { TECH_LOG_STUDIO_CONTRIBUTION } from "./tech-log/contracts/tech-log-stud
export const INSTALLED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] =
Object.freeze(
INSTALLED_PRODUCT_FEATURE_IDS.includes(REFERENCE_FEATURE_ID)
? [REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION, TECH_LOG_STUDIO_CONTRIBUTION]
: [TECH_LOG_STUDIO_CONTRIBUTION],
? [
REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION,
TECH_LOG_STUDIO_CONTRIBUTION,
TECH_LOG_PUBLIC_CONTRIBUTION,
]
: [TECH_LOG_STUDIO_CONTRIBUTION, TECH_LOG_PUBLIC_CONTRIBUTION],
);
export const COMPOSED_CONTRACT_CONTRIBUTIONS = composeContractContributions(
@@ -12,6 +12,7 @@ import {
import type { CsrfTokenProvider } from "./http/studio-session-csrf.ts";
import { createMockStudioAssetGateway } from "./mock/mock-studio-asset-gateway.ts";
import { createMockStudioGateway } from "./mock/mock-studio-gateway.ts";
import { createHttpPublicContentGateway } from "./http/http-public-content-gateway.ts";
import { publicContentQueries } from "./static/public-query.ts";
/**
@@ -67,8 +68,16 @@ export function createTechLogFeatureInstalledInput(
? createMockStudioGateway({ assets: mockAssets })
: createHttpStudioGateway({ operations: context.contractOperations });
// The public read source switches independently of Studio: the two are
// different services, and the combination that matters today is an authoring
// backend that is live while the public read API is not.
const publicContent =
context.publicSource === "MOCK"
? publicContentQueries
: createHttpPublicContentGateway({ operations: context.contractOperations });
const input: TechLogFeatureInput = Object.freeze({
publicContent: publicContentQueries,
publicContent,
createStudioGateway,
createStudioAssetGateway,
});
@@ -0,0 +1,353 @@
import type {
HomeFocusItem,
ProjectActivity,
ProjectDecision,
Project,
PublicContentQueries,
PublicRecord,
QuestionRecord,
RecordFilters,
RecordKind,
Release,
SearchablePublicEntity,
} from "../../application/ports/public-content-queries.ts";
import {
activityItemToActivity,
baseOf,
dateLabel,
decisionItemToDecision,
flattenRelations,
knowledgeListItemToRecord,
markdownLines,
markdownSections,
questionListItemToRecord,
releaseDetailToRelease,
searchItemToEntity,
} from "./public-content-mapping.ts";
import type { StudioOperationExecutor } from "./http-studio-gateway.ts";
const ROUTE_ID = "TECH_LOG_PUBLIC";
/**
* A missing slug is an answer, not a failure.
*
* The port returns `undefined` for a record that is not published, and the
* screens turn that into their not-found route. So a 404 is unwrapped here
* rather than thrown — throwing would put the terminal-error surface on a page
* whose real state is "this does not exist".
*/
const NOT_FOUND = Symbol("not-found");
export type PublicContentGatewayError = Error & { readonly failure?: unknown };
function gatewayError(operationId: string, detail: string): PublicContentGatewayError {
const error = new Error(`${operationId}: ${detail}`) as PublicContentGatewayError;
error.name = "PublicContentGatewayError";
return error;
}
export function createHttpPublicContentGateway(
deps: Readonly<{ operations: StudioOperationExecutor }>,
): PublicContentQueries {
async function read<T>(operationId: string, input: unknown): Promise<T | typeof NOT_FOUND> {
const outcome = await deps.operations.execute(operationId, input, { routeId: ROUTE_ID });
if (outcome.kind === "SUCCESS") return outcome.value as T;
if (outcome.kind === "PROBLEM") {
const problem = outcome.problem as Readonly<{ status?: number; code?: string }> | null;
if (problem?.status === 404 || problem?.code === "NOT_FOUND") return NOT_FOUND;
throw gatewayError(operationId, problem?.code ?? "PROBLEM");
}
throw gatewayError(operationId, outcome.kind);
}
async function readOrThrow<T>(operationId: string, input: unknown): Promise<T> {
const value = await read<T>(operationId, input);
if (value === NOT_FOUND) throw gatewayError(operationId, "NOT_FOUND");
return value;
}
type Page = Readonly<{ items?: readonly Readonly<Record<string, unknown>>[] }>;
/**
* `listRecords` is one port method over two endpoints: the contract splits
* knowledge (Case, Reference) from questions because they page and filter
* differently. A caller that asks for one kind must not pay for the other, so
* the unfiltered call is the only one that fans out.
*/
async function listRecords(filters: RecordFilters = {}): Promise<PublicRecord[]> {
const wantsQuestions = !filters.kind || filters.kind === "QUESTION";
const wantsKnowledge = !filters.kind || filters.kind !== "QUESTION";
const query = {
...(filters.topic ? { topic: filters.topic } : {}),
...(filters.project ? { project: filters.project } : {}),
};
const [knowledge, questions] = await Promise.all([
wantsKnowledge
? readOrThrow<Page>("exploreKnowledge", {
...query,
...(filters.kind && filters.kind !== "QUESTION" ? { type: filters.kind } : {}),
})
: Promise.resolve({ items: [] } as Page),
wantsQuestions
? readOrThrow<Page>("exploreQuestions", {
...query,
...(filters.openQuestionsOnly ? { status: "OPEN" } : {}),
})
: Promise.resolve({ items: [] } as Page),
]);
const records = [
...(knowledge.items ?? []).map(knowledgeListItemToRecord).filter((r): r is PublicRecord => r !== null),
...(questions.items ?? []).map(questionListItemToRecord),
];
return records.sort((left, right) => right.publishedAt.localeCompare(left.publishedAt));
}
/**
* The cast at each return is not laziness. `kind` is a generic parameter, so
* narrowing it inside the body does not narrow `Extract<PublicRecord, {kind: K}>`
* with it — the compiler cannot know the branch it took corresponds to the K it
* was given. The discriminant on each object is a literal, so the shape is
* checked; only the tie back to K is asserted.
*/
async function getRecord<K extends RecordKind>(
kind: K,
slug: string,
): Promise<Extract<PublicRecord, { kind: K }> | undefined> {
const operationId =
kind === "CASE" ? "getPublicCase" : kind === "REFERENCE" ? "getPublicReference" : "getPublicQuestion";
const detail = await read<Readonly<Record<string, unknown>>>(operationId, { slug });
if (detail === NOT_FOUND) return undefined;
const canonicalPath = String(detail.canonicalPath ?? "");
const groups = (detail.relations as Readonly<Record<string, never>>) ?? {};
if (kind === "CASE") {
const body = (detail.case as Readonly<Record<string, unknown>>) ?? {};
return Object.freeze({
...baseOf("CASE", slug, {
title: body.title as string,
summary: body.problemSummary as string,
path: canonicalPath,
primaryTopic: body.primaryTopic as never,
primaryProject: body.primaryProject as never,
publishedAt: body.publishedAt as string,
relations: flattenRelations(groups, {
originQuestion: "이 기록이 시작된 질문",
projectDecisions: "이 기록이 뒷받침하는 결정",
derivedReferences: "이 기록에서 정리된 기준",
relatedCases: "관련 기록",
}),
}),
kind: "CASE",
problem: (body.problemSummary as string) ?? "",
conclusion: (body.conclusionSummary as string) ?? "",
environment: ((body.environmentSummary as readonly string[]) ?? []).join(", "),
// The Case document renders a verification line. The contract has no
// field for it — verification lives in the body — so it stays empty
// rather than being guessed from a heading.
verification: "",
lastVerifiedLabel: dateLabel(body.lastVerifiedAt as string),
sections: markdownSections(body.content as string),
}) as unknown as Extract<PublicRecord, { kind: K }>;
}
if (kind === "REFERENCE") {
const body = (detail.reference as Readonly<Record<string, unknown>>) ?? {};
return Object.freeze({
...baseOf("REFERENCE", slug, {
title: body.title as string,
summary: body.purposeSummary as string,
path: canonicalPath,
primaryTopic: body.primaryTopic as never,
primaryProject: body.primaryProject as never,
publishedAt: body.publishedAt as string,
relations: flattenRelations(groups, {
originCases: "이 기준이 나온 기록",
projectDecisions: "이 기준을 따르는 결정",
relatedReferences: "관련 기준",
}),
}),
kind: "REFERENCE",
purpose: (body.purposeSummary as string) ?? "",
rules: Object.freeze(
markdownSections(body.content as string).map((section) => ({
title: section.title,
body: section.paragraphs.join("\n"),
})),
),
applyWhen: Object.freeze(markdownLines(body.applyWhenMarkdown as string)),
exceptions: Object.freeze(markdownLines(body.exceptionsMarkdown as string)),
examples: Object.freeze(markdownLines(body.examplesMarkdown as string)),
verifiedAt: dateLabel(body.lastVerifiedAt as string),
}) as unknown as Extract<PublicRecord, { kind: K }>;
}
const body = (detail.question as Readonly<Record<string, unknown>>) ?? {};
const points = (body.points as readonly Readonly<Record<string, unknown>>[] | undefined) ?? [];
const pointsOf = (group: string) =>
Object.freeze(
points
.filter((point) => point.group === group)
.flatMap((point) => (point.items as readonly string[] | undefined) ?? []),
);
return Object.freeze({
...baseOf("QUESTION", slug, {
title: body.question as string,
summary: body.summary as string,
path: canonicalPath,
primaryTopic: body.primaryTopic as never,
primaryProject: body.primaryProject as never,
publishedAt: body.updatedAt as string,
relations: flattenRelations(groups, {
derivedCases: "이 질문에서 나온 기록",
projectDecisions: "이 질문이 이끈 결정",
relatedQuestions: "관련 질문",
}),
}),
kind: "QUESTION",
questionStatus: (body.status as QuestionRecord["questionStatus"]) ?? "OPEN",
facts: pointsOf("KNOWN_FACT"),
assumptions: pointsOf("ASSUMPTION"),
unknowns: pointsOf("UNRESOLVED"),
constraints: pointsOf("CONSTRAINT"),
options: Object.freeze([]),
nextValidation: (body.nextVerification as string) ?? "",
}) as unknown as Extract<PublicRecord, { kind: K }>;
}
async function getProject(slug: string): Promise<Project | undefined> {
const detail = await read<Readonly<Record<string, unknown>>>("getPublicProject", { slug });
if (detail === NOT_FOUND) return undefined;
const body = (detail.project as Readonly<Record<string, unknown>>) ?? {};
const [decisions, activity] = await Promise.all([
getProjectDecisions(slug),
getProjectActivity(slug),
]);
return Object.freeze({
slug,
title: String(body.name ?? ""),
summary: String(body.oneLinePurpose ?? ""),
thesis: String(body.purpose ?? body.oneLinePurpose ?? ""),
stage: body.phase === "VALIDATION" ? "VALIDATION" : "DESIGN",
currentGoal: String(body.currentObjective ?? ""),
nextStep: String(body.nextStep ?? ""),
topics: Object.freeze(
((body.topics as readonly Readonly<{ name?: string }>[] | undefined) ?? [])
.map((topic) => topic.name ?? "")
.filter((name) => name.length > 0),
),
decisions: Object.freeze(decisions),
activity: Object.freeze(activity),
});
}
async function getProjectDecisions(projectSlug: string): Promise<ProjectDecision[]> {
const page = await read<Page>("listPublicProjectDecisions", { slug: projectSlug });
if (page === NOT_FOUND) return [];
return (page.items ?? []).map(decisionItemToDecision);
}
async function getProjectActivity(projectSlug: string): Promise<ProjectActivity[]> {
const page = await read<Page>("listPublicProjectActivities", { slug: projectSlug });
if (page === NOT_FOUND) return [];
return (page.items ?? []).map(activityItemToActivity);
}
async function getProjectRecords(projectSlug: string): Promise<PublicRecord[]> {
const page = await read<Page>("listPublicProjectRecords", { slug: projectSlug });
if (page === NOT_FOUND) return [];
return (page.items ?? [])
.map(knowledgeListItemToRecord)
.filter((record): record is PublicRecord => record !== null);
}
async function getRelease(version: string): Promise<Release | undefined> {
const detail = await read<Readonly<Record<string, unknown>>>("getPublicRelease", { version });
if (detail === NOT_FOUND) return undefined;
return releaseDetailToRelease(detail, version);
}
/**
* The home screen shows up to three focus cards. The contract returns them as
* one object with a named slot per kind rather than a list, because each slot
* has its own shape; the order below is the order the screen renders them in.
*/
async function getHomeFocusItems(): Promise<HomeFocusItem[]> {
const home = await read<Readonly<{ focus?: Readonly<Record<string, never>> }>>(
"getPublicHome",
{},
);
if (home === NOT_FOUND) return [];
const focus = (home.focus ?? {}) as Readonly<Record<string, Readonly<Record<string, unknown>>>>;
const items: HomeFocusItem[] = [];
const work = focus.currentWork;
if (work) {
items.push(
Object.freeze({
key: "current",
label: "지금 하는 일",
title: String(work.projectName ?? ""),
summary: String(work.purpose ?? ""),
details: Object.freeze([
{ label: "단계", value: String(work.phase ?? "") },
{ label: "현재 목표", value: String(work.currentObjective ?? "") },
{ label: "다음 작업", value: String(work.nextStep ?? "") },
]),
targetPath: String(work.projectPath ?? "/projects"),
}),
);
}
const question = focus.openQuestion;
if (question) {
items.push(
Object.freeze({
key: "question",
label: "열린 질문",
title: String(question.question ?? ""),
summary: String(question.summary ?? ""),
details: Object.freeze([
{ label: "확인한 사실", value: ((question.knownFacts as readonly string[]) ?? []).join(" · ") },
{ label: "미해결", value: ((question.unresolvedPoints as readonly string[]) ?? []).join(" · ") },
{ label: "다음 검증", value: String(question.nextVerification ?? "") },
]),
targetPath: String(question.questionPath ?? "/explore/questions"),
}),
);
}
const decision = focus.recentDecision;
if (decision) {
items.push(
Object.freeze({
key: "decision",
label: "최근 결정",
title: String(decision.statement ?? ""),
summary: String(decision.rationale ?? ""),
details: Object.freeze([
{ label: "결정일", value: dateLabel(decision.decidedAt as string) },
{ label: "영향", value: ((decision.consequences as readonly string[]) ?? []).join(" · ") },
]),
targetPath: String(decision.decisionPath ?? "/projects"),
}),
);
}
return items;
}
async function searchPublicContent(query: string): Promise<SearchablePublicEntity[]> {
const page = await read<Page>("searchPublicResources", query ? { q: query } : {});
if (page === NOT_FOUND) return [];
return (page.items ?? []).map(searchItemToEntity);
}
return Object.freeze({
listRecords,
getRecord,
getProject,
getRelease,
getProjectRecords,
getProjectDecisions,
getProjectActivity,
getHomeFocusItems,
searchPublicContent,
});
}
@@ -0,0 +1,313 @@
import type {
CaseRecord,
ProjectActivity,
ProjectDecision,
PublicRecord,
QuestionRecord,
RecordSection,
ReferenceRecord,
Release,
SearchablePublicEntity,
} from "../../application/ports/public-content-queries.ts";
/**
* The contract and the screens disagree about shape, on purpose.
*
* The contract speaks in what the server stores — timestamps, one markdown body,
* relations grouped by their kind. The screens were built against a catalog that
* spoke in what a page renders — formatted labels, sections, one flat relation
* list. Neither is wrong, and translating here rather than at either end is what
* keeps the presentation components untouched by this migration.
*
* Where the contract has no counterpart the value is empty rather than invented,
* and the gap is named at the call site.
*/
const DATE_LABEL = new Intl.DateTimeFormat("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
timeZone: "UTC",
});
/** `2026. 08. 20.` → `2026.08.20`, the form the fixture used. */
export function dateLabel(value: string | null | undefined): string {
if (!value) return "";
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return "";
return DATE_LABEL.format(parsed).replaceAll(" ", "").replace(/\.$/u, "");
}
export function isoDate(value: string | null | undefined): string {
if (!value) return "";
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? "" : parsed.toISOString();
}
/**
* The contract carries one markdown body; the document components render an
* ordered list of titled sections. Splitting on `##` reproduces that structure
* without a full renderer: everything before the first heading is the lead, and
* each heading opens a section whose bullets are its `-`/`*` lines.
*
* This is deliberately not the Studio parser (`parseCaseContent`). That one
* produces the canonical render-block union the editor needs — inline marks,
* evidence directives, tables — which is a richer tree than `RecordSection` can
* hold. Reusing it would mean flattening its output back down to this shape, and
* flattening loses exactly the blocks that made it worth using.
*/
export function markdownSections(body: string | null | undefined): RecordSection[] {
if (!body) return [];
const sections: RecordSection[] = [];
let current: { id: string; title: string; paragraphs: string[]; bullets: string[] } | null = null;
const flush = () => {
if (!current) return;
sections.push(
Object.freeze({
id: current.id,
title: current.title,
paragraphs: Object.freeze([...current.paragraphs]),
...(current.bullets.length > 0 ? { bullets: Object.freeze([...current.bullets]) } : {}),
}),
);
};
for (const rawLine of body.split(/\r?\n/u)) {
const line = rawLine.trim();
const heading = /^#{2,3}\s+(.*)$/u.exec(line);
if (heading) {
flush();
const title = heading[1]!.trim();
current = { id: slugOf(title, sections.length), title, paragraphs: [], bullets: [] };
continue;
}
if (!current) {
if (line.length === 0) continue;
current = { id: "lead", title: "", paragraphs: [], bullets: [] };
}
if (line.length === 0) continue;
const bullet = /^[-*]\s+(.*)$/u.exec(line);
if (bullet) current.bullets.push(bullet[1]!.trim());
else current.paragraphs.push(line);
}
flush();
return sections;
}
/** Markdown that is really a list — the release document's four bodies are. */
export function markdownLines(body: string | null | undefined): string[] {
if (!body) return [];
return body
.split(/\r?\n/u)
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map((line) => line.replace(/^[-*]\s+/u, ""));
}
function slugOf(title: string, index: number): string {
const normalized = title
.toLocaleLowerCase("ko-KR")
.replace(/[^\p{Letter}\p{Number}]+/gu, "-")
.replace(/^-+|-+$/gu, "");
return normalized.length > 0 ? normalized : `section-${index + 1}`;
}
type Related = Readonly<{ type?: string; title?: string; summary?: string; path?: string }>;
/**
* The contract groups relations by why they relate (origin question, derived
* references, related cases); the renderer takes one list where the reason is a
* label. Flattening keeps the group name as that label.
*/
export function flattenRelations(
groups: Readonly<Record<string, Related | readonly Related[] | undefined>>,
labels: Readonly<Record<string, string>>,
): ReadonlyArray<{ reason: string; title: string; path: string }> {
const flat: { reason: string; title: string; path: string }[] = [];
for (const [group, value] of Object.entries(groups)) {
if (!value) continue;
const reason = labels[group] ?? group;
for (const entry of Array.isArray(value) ? value : [value as Related]) {
if (!entry?.path || !entry.title) continue;
flat.push({ reason, title: entry.title, path: entry.path });
}
}
return Object.freeze(flat);
}
type Summary = Readonly<{ name?: string; slug?: string; path?: string }> | undefined;
export function baseOf(
kind: PublicRecord["kind"],
slug: string,
fields: Readonly<{
title?: string;
summary?: string;
path?: string;
primaryTopic?: Summary;
primaryProject?: Summary;
publishedAt?: string | null;
relations?: ReadonlyArray<{ reason: string; title: string; path: string }>;
}>,
) {
return {
kind,
slug,
title: fields.title ?? "",
summary: fields.summary ?? "",
path: fields.path ?? "",
topic: fields.primaryTopic?.name ?? "",
topicSlug: fields.primaryTopic?.slug ?? "",
projectSlug: fields.primaryProject?.slug ?? "",
projectTitle: fields.primaryProject?.name ?? "",
publishedAt: isoDate(fields.publishedAt),
publishedLabel: dateLabel(fields.publishedAt),
visibility: "PUBLIC" as const,
relations: fields.relations ?? Object.freeze([]),
};
}
/**
* A list endpoint answers with what a list row needs, not with a whole document.
* The port's type is the full record, so the detail fields are filled empty here
* and the detail screens fetch by slug. That is the same trip the fixture made
* for free; it is a real extra request now, and the alternative — widening the
* list response — would send every body to render a title.
*/
export function knowledgeListItemToRecord(item: Readonly<Record<string, unknown>>): PublicRecord | null {
const type = String(item.type ?? "");
const path = String(item.path ?? "");
const slug = path.split("/").filter(Boolean).pop() ?? "";
const base = baseOf(type === "REFERENCE" ? "REFERENCE" : "CASE", slug, {
title: item.title as string,
summary: (item.primarySummary as string) ?? "",
path,
primaryTopic: item.primaryTopic as Summary,
primaryProject: item.primaryProject as Summary,
publishedAt: item.publishedAt as string,
});
if (type === "CASE") {
return Object.freeze({
...base,
kind: "CASE",
problem: (item.primarySummary as string) ?? "",
conclusion: (item.secondarySummary as string) ?? "",
environment: "",
verification: "",
lastVerifiedLabel: dateLabel(item.lastVerifiedAt as string),
sections: Object.freeze([]),
}) as CaseRecord;
}
if (type === "REFERENCE") {
return Object.freeze({
...base,
kind: "REFERENCE",
purpose: (item.primarySummary as string) ?? "",
rules: Object.freeze([]),
applyWhen: Object.freeze([]),
exceptions: Object.freeze([]),
examples: Object.freeze([]),
verifiedAt: dateLabel(item.lastVerifiedAt as string),
}) as ReferenceRecord;
}
return null;
}
export function questionListItemToRecord(item: Readonly<Record<string, unknown>>): QuestionRecord {
const path = String(item.path ?? "");
const slug = path.split("/").filter(Boolean).pop() ?? "";
return Object.freeze({
...baseOf("QUESTION", slug, {
title: item.question as string,
summary: (item.summary as string) ?? "",
path,
primaryProject: item.primaryProject as Summary,
publishedAt: item.updatedAt as string,
}),
kind: "QUESTION",
questionStatus: (item.status as QuestionRecord["questionStatus"]) ?? "OPEN",
facts: Object.freeze([]),
assumptions: Object.freeze([]),
unknowns: Object.freeze([]),
constraints: Object.freeze([]),
options: Object.freeze([]),
nextValidation: (item.nextVerification as string) ?? "",
}) as QuestionRecord;
}
export function decisionItemToDecision(item: Readonly<Record<string, unknown>>): ProjectDecision {
const sources = [item.sourceQuestion, item.sourceCase]
.filter((entry): entry is Related => Boolean(entry))
.map((entry) => ({ title: entry.title ?? "", path: entry.path ?? "" }));
return Object.freeze({
id: String(item.id ?? ""),
status: (item.status as ProjectDecision["status"]) ?? "PROPOSED",
date: dateLabel(item.decidedAt as string),
title: String(item.statement ?? ""),
statement: String(item.statement ?? ""),
rationale: String(item.rationaleSummary ?? ""),
// The list response carries a rationale summary, not the consequence list the
// decision screen renders; the contract has no field for it here.
consequences: Object.freeze([]),
evidence: Object.freeze(sources),
});
}
export function activityItemToActivity(
item: Readonly<Record<string, unknown>>,
index: number,
): ProjectActivity {
const occurredAt = item.occurredAt as string;
const relatedPath = (item.relatedPath as string) ?? "";
return Object.freeze({
id: `activity-${index + 1}`,
date: dateLabel(occurredAt),
dateTime: isoDate(occurredAt),
type: (item.type as ProjectActivity["type"]) ?? "PROJECT UPDATE",
title: String(item.title ?? ""),
summary: String(item.summary ?? ""),
path: relatedPath,
...(relatedPath ? { recordPath: relatedPath } : {}),
});
}
export function releaseDetailToRelease(
detail: Readonly<Record<string, unknown>>,
version: string,
): Release {
const related = (detail.relatedRecords as readonly Related[] | undefined) ?? [];
return Object.freeze({
version: String(detail.version ?? version),
path: `/releases/${String(detail.version ?? version)}`,
title: String(detail.title ?? ""),
summary: String(detail.summary ?? ""),
publishedAt: isoDate(detail.releasedOn as string),
publishedLabel: dateLabel(detail.releasedOn as string),
changes: Object.freeze(markdownLines(detail.changesMarkdown as string)),
reasons: Object.freeze(markdownLines(detail.reasonMarkdown as string)),
impacts: Object.freeze([
...markdownLines(detail.userImpactMarkdown as string),
...markdownLines(detail.implementationImpactMarkdown as string),
]),
related: Object.freeze(
related
.filter((entry) => entry.path && entry.title)
.map((entry) => ({ title: entry.title!, path: entry.path! })),
),
});
}
export function searchItemToEntity(
item: Readonly<Record<string, unknown>>,
): SearchablePublicEntity {
const topic = (item.primaryTopic as Summary)?.name;
const project = (item.primaryProject as Summary)?.name;
return Object.freeze({
contentType: (item.contentType as SearchablePublicEntity["contentType"]) ?? "CASE",
title: String(item.title ?? ""),
summary: String(item.snippet ?? ""),
path: String(item.path ?? ""),
...(topic ? { topic } : {}),
...(project ? { project } : {}),
...(item.publishedAt ? { publishedAt: isoDate(item.publishedAt as string) } : {}),
});
}
@@ -0,0 +1,160 @@
import type {
InstalledContractContribution,
InstalledHttpContract,
} from "../../../contracts/external-contract-runtime.ts";
import { TECH_LOG_FEATURE_ID } from "../application/tech-log-feature-input.ts";
import canonicalSource from "./public/canonical-source.json" with { type: "json" };
import { envelopeData, envelopeError, passthroughInput } from "./tech-log-studio-contract-contribution.ts";
type PathValues = Readonly<Record<string, string>>;
type QueryEntries = readonly (readonly [string, string])[];
const NO_PATH: PathValues = Object.freeze({});
const NO_QUERY = Object.freeze([]) as QueryEntries;
const PROBLEM = envelopeError();
function queryOf(input: Readonly<Record<string, unknown>>): QueryEntries {
const entries: (readonly [string, string])[] = [];
for (const [key, value] of Object.entries(input)) {
if (value === undefined || value === null || value === "") continue;
entries.push([key, String(value)]);
}
return Object.freeze(entries);
}
/**
* Every public operation has the same shape, which is the point of holding this
* surface apart from Studio: all 18 are GET, none carries a body, none needs a
* session, and none needs a CSRF token. The `ANONYMOUS` auth profile is what
* states that — it forbids credentials outright, so a future change that starts
* sending the session cookie on a public read fails the profile check rather
* than silently making the cache-friendly surface user-specific.
*/
function publicRead(
operationId: string,
pathTemplate: string,
responseByteLimit: number,
project: (input: never) => Readonly<{ pathValues: PathValues; queryEntries: QueryEntries }> = () =>
Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY }),
): InstalledHttpContract<unknown, unknown, unknown> {
return Object.freeze({
contract: Object.freeze({
operationId,
method: "GET" as const,
pathTemplate,
inputValidator: passthroughInput(`${operationId}Input`),
outputValidator: envelopeData(`${operationId}Output`),
problemValidator: PROBLEM,
// 404 is a normal answer here — a slug that is not published — so it is
// mapped by the gateway rather than treated as a transport failure.
acceptedStatuses: Object.freeze([200]),
emptyBodyStatuses: Object.freeze([]),
retrySemantics: "SAFE" as const,
requestBody: "NONE" as const,
responseBody: "REQUIRED_JSON" as const,
commandRecovery: null,
commandEffect: null,
projectRequest(input: never) {
return Object.freeze({ ...project(input), body: null });
},
}),
frontend: Object.freeze({
policyId: `${operationId}_V1`,
requestByteLimit: 0,
responseByteLimit,
totalDeadlineMs: 10_000,
retryBudget: 2 as const,
authProfileId: "ANONYMOUS",
diagnosticsOperation: `techLog.public.${operationId}`,
}),
}) as InstalledHttpContract<unknown, unknown, unknown>;
}
const bySlug = (input: never) => {
const value = input as unknown as Readonly<{ slug: string }>;
return Object.freeze({
pathValues: Object.freeze({ slug: value.slug }),
queryEntries: NO_QUERY,
});
};
const P = "/api/v1/public";
const HTTP_CONTRACTS = Object.freeze([
publicRead("getPublicSite", `${P}/site`, 32_768),
publicRead("getPublicHome", `${P}/home`, 262_144),
publicRead("exploreKnowledge", `${P}/explore/knowledge`, 262_144, (input: never) => {
const value = input as unknown as Readonly<{
type?: string;
topic?: string;
project?: string;
page?: number;
size?: number;
}>;
return Object.freeze({ pathValues: NO_PATH, queryEntries: queryOf(value ?? {}) });
}),
publicRead("exploreQuestions", `${P}/explore/questions`, 262_144, (input: never) => {
const value = input as unknown as Readonly<{
status?: string;
topic?: string;
project?: string;
page?: number;
size?: number;
}>;
return Object.freeze({ pathValues: NO_PATH, queryEntries: queryOf(value ?? {}) });
}),
publicRead("listPublicTopics", `${P}/topics`, 65_536),
publicRead("getPublicTopic", `${P}/topics/{topicSlug}`, 262_144, (input: never) => {
const value = input as unknown as Readonly<{ topicSlug: string }>;
return Object.freeze({
pathValues: Object.freeze({ topicSlug: value.topicSlug }),
queryEntries: NO_QUERY,
});
}),
publicRead("getPublicCase", `${P}/cases/{slug}`, 524_288, bySlug),
publicRead("getPublicReference", `${P}/references/{slug}`, 524_288, bySlug),
publicRead("getPublicQuestion", `${P}/questions/{slug}`, 524_288, bySlug),
publicRead("listPublicProjects", `${P}/projects`, 262_144),
publicRead("getPublicProject", `${P}/projects/{slug}`, 262_144, bySlug),
publicRead("listPublicProjectDecisions", `${P}/projects/{slug}/decisions`, 262_144, bySlug),
publicRead("listPublicProjectRecords", `${P}/projects/{slug}/records`, 262_144, bySlug),
publicRead("listPublicProjectActivities", `${P}/projects/{slug}/activities`, 262_144, bySlug),
publicRead("listPublicReleases", `${P}/releases`, 262_144),
publicRead("getPublicRelease", `${P}/releases/{version}`, 262_144, (input: never) => {
const value = input as unknown as Readonly<{ version: string }>;
return Object.freeze({
pathValues: Object.freeze({ version: value.version }),
queryEntries: NO_QUERY,
});
}),
publicRead("getPublicProfile", `${P}/profile`, 65_536),
publicRead("searchPublicResources", `${P}/search`, 262_144, (input: never) => {
const value = input as unknown as Readonly<{ q?: string; page?: number; size?: number }>;
return Object.freeze({ pathValues: NO_PATH, queryEntries: queryOf(value ?? {}) });
}),
]);
export const TECH_LOG_PUBLIC_OPERATION_IDS = Object.freeze(
HTTP_CONTRACTS.map((entry) => entry.contract.operationId),
);
export type TechLogPublicOperationId =
(typeof TECH_LOG_PUBLIC_OPERATION_IDS)[number];
export const TECH_LOG_PUBLIC_CONTRIBUTION: InstalledContractContribution =
Object.freeze({
contributionId: "tech-log-public-http-v1",
featureId: TECH_LOG_FEATURE_ID,
source: Object.freeze({
kind: "EXTERNAL_PACKAGE" as const,
package: Object.freeze({
packageId: canonicalSource.packageId,
version: canonicalSource.version,
digest: canonicalSource.digest as `sha256:${string}`,
runtimeProtocolVersion: 1 as const,
sourceRevision: canonicalSource.sourceRevision,
}),
}),
http: HTTP_CONTRACTS,
events: Object.freeze([]),
});
@@ -52,6 +52,12 @@ function zodValidator<T>(schemaId: string, schema: z.ZodType<T>): RuntimeValidat
const passthrough = <T>(schemaId: string) =>
zodValidator<T>(schemaId, z.unknown() as unknown as z.ZodType<T>);
/**
* Shared with the public contribution: both surfaces project their request
* inputs in code, so neither re-validates them at the transport boundary.
*/
export const passthroughInput = passthrough;
/**
* wire format은 봉투다 (ADR-006). 전송 계층은 봉투 뼈대만 검증하고 payload는
* 통과시킨다 — generated 타입이 컴파일 시점 계약이고, 런타임 재검증은 계약 갱신