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:
@@ -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 타입이 컴파일 시점 계약이고, 런타임 재검증은 계약 갱신
|
||||
|
||||
Reference in New Issue
Block a user