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
@@ -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 타입이 컴파일 시점 계약이고, 런타임 재검증은 계약 갱신