feat: add the TechLog asset multipart upload transport

Wires the whole Asset capability into the running application: the
multipart upload transport (the contract runtime can only express JSON
bodies), a single composition-root-owned CSRF provider shared between
the platform's credential collaborator (18 JSON operations) and the
upload transport (1 multipart operation), and Studio/StudioShell
exposure of the Asset gateway alongside the existing document gateway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-18 02:39:51 +09:00
co-authored by Claude Opus 5
parent d9c2d8bc5e
commit c9c832c365
15 changed files with 473 additions and 3 deletions
+7 -1
View File
@@ -2,6 +2,7 @@ import type { ApplicationFeatureInputs } from "../application/ports/in/applicati
import { createReferenceFeatureInstalledInput } from "./reference-feature/adapters/create-reference-feature-input.ts";
import { REFERENCE_FEATURE_ID } from "./reference-feature/contracts/reference-feature-contract.ts";
import { createTechLogFeatureInstalledInput } from "./tech-log/adapters/create-tech-log-feature-input.ts";
import type { CsrfTokenProvider } from "./tech-log/adapters/http/studio-session-csrf.ts";
import { TECH_LOG_FEATURE_ID } from "./tech-log/application/tech-log-feature-input.ts";
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
@@ -20,7 +21,12 @@ type InstalledFeatureInputs = Readonly<
export function createInstalledFeatureInputs(
context: Parameters<typeof createReferenceFeatureInstalledInput>[0] &
Readonly<{ studioSource: "MOCK" | "HTTP" }>,
Readonly<{
studioSource: "MOCK" | "HTTP";
apiBaseUrl: string;
requestTimeoutMs: number;
csrf: CsrfTokenProvider;
}>,
): InstalledFeatureInputs {
const techLogFeature = createTechLogFeatureInstalledInput(context);
if (!INSTALLED_PRODUCT_FEATURE_IDS.includes(REFERENCE_FEATURE_ID)) {
@@ -2,10 +2,13 @@ import {
TECH_LOG_FEATURE_ID,
type TechLogFeatureInput,
} from "../application/tech-log-feature-input.ts";
import { createAssetUploadTransport } from "./http/asset-upload-transport.ts";
import { createHttpStudioAssetGateway } from "./http/http-studio-asset-gateway.ts";
import {
createHttpStudioGateway,
type StudioOperationExecutor,
} from "./http/http-studio-gateway.ts";
import type { CsrfTokenProvider } from "./http/studio-session-csrf.ts";
import { createMockStudioGateway } from "./mock/mock-studio-gateway.ts";
import { publicContentQueries } from "./static/public-query.ts";
@@ -15,15 +18,35 @@ import { publicContentQueries } from "./static/public-query.ts";
* platform's `attachCredentials` collaborator at the composition root. This
* context only has to say which adapter to construct and hand it the
* composed contract executor.
*
* CSRF has exactly one provider per Studio session, owned by the composition
* root — it is shared with the platform's credential collaborator, so it is
* threaded in here rather than constructed locally.
*/
export type TechLogInstallContext = Readonly<{
studioSource: "MOCK" | "HTTP";
contractOperations: StudioOperationExecutor;
apiBaseUrl: string;
requestTimeoutMs: number;
csrf: CsrfTokenProvider;
}>;
export function createTechLogFeatureInstalledInput(
context: TechLogInstallContext,
) {
// The Asset gateway is needed even on MOCK: with no backend behind it, the
// list comes back empty and uploads fail with a transport error — the UI
// surfacing that state is the correct behavior, not a bug to route around.
const createStudioAssetGateway = () =>
createHttpStudioAssetGateway({
operations: context.contractOperations,
csrf: context.csrf,
upload: createAssetUploadTransport({
baseUrl: context.apiBaseUrl,
timeoutMs: context.requestTimeoutMs,
}),
});
const createStudioGateway = () =>
context.studioSource === "MOCK"
? createMockStudioGateway()
@@ -32,6 +55,7 @@ export function createTechLogFeatureInstalledInput(
const input: TechLogFeatureInput = Object.freeze({
publicContent: publicContentQueries,
createStudioGateway,
createStudioAssetGateway,
});
return Object.freeze({
@@ -0,0 +1,85 @@
import { StudioGatewayError } from "../../application/ports/studio-gateway-error.ts";
import type { UploadAssetForm } from "../../application/ports/studio-asset-gateway.ts";
import type { Asset, ProblemDetails } from "../../contracts/studio/contract.ts";
import type { StudioAssetUploadTransport } from "./http-studio-asset-gateway.ts";
import { STUDIO_ERROR_CODES } from "./studio-error-mapping.ts";
const CODES = new Set<string>(STUDIO_ERROR_CODES);
/**
* The contract runtime can only express `requestBody: "NONE" | "JSON"` and the
* low-level client always serializes the body as JSON (see
* `external-contract-runtime.ts` and `client.ts:719`). The canonical
* `POST /assets` is multipart, so this one operation is split into a narrow
* seam instead. If upload moves to presigned/resumable transfer, or the
* platform grows a MULTIPART mode, only this file is replaced.
*/
export function createAssetUploadTransport(
deps: Readonly<{
baseUrl: string;
timeoutMs: number;
fetch?: typeof globalThis.fetch;
}>,
): StudioAssetUploadTransport {
const doFetch = deps.fetch ?? globalThis.fetch.bind(globalThis);
const endpoint = new URL("api/v1/studio/assets", deps.baseUrl).href;
function unavailable(detail: string): StudioGatewayError {
return new StudioGatewayError({
type: "https://techlog.local/problems/studio-unavailable",
title: "STUDIO_UNAVAILABLE",
status: 503,
detail,
code: "STUDIO_UNAVAILABLE",
retryable: true,
});
}
return Object.freeze({
async upload(form: UploadAssetForm, headers, options) {
const body = new FormData();
body.append("file", form.file, form.file.name);
body.append("kind", form.kind);
if (form.altText !== undefined) body.append("altText", form.altText);
if (form.decorative !== undefined) body.append("decorative", String(form.decorative));
const timeout = AbortSignal.timeout(deps.timeoutMs);
const signal = options?.signal
? AbortSignal.any([options.signal, timeout])
: timeout;
let response: Response;
try {
// content-type is never set by hand here. fetch generates the
// multipart boundary; setting it manually produces a body the server
// cannot parse.
response = await doFetch(endpoint, {
method: "POST",
headers: { ...headers },
body,
signal,
credentials: "include",
});
} catch (error) {
throw unavailable(
error instanceof Error ? `Upload transport failed: ${error.message}` : "Upload transport failed.",
);
}
if (response.status === 201 || response.status === 200) {
return (await response.json()) as Asset;
}
let problem: ProblemDetails | null;
try {
problem = (await response.json()) as ProblemDetails;
} catch {
problem = null;
}
if (problem && typeof problem.code === "string" && CODES.has(problem.code)) {
throw new StudioGatewayError(problem);
}
throw unavailable(`Upload returned an uncontracted status ${response.status}.`);
},
});
}
@@ -1,4 +1,5 @@
import type { PublicContentQueries } from "./ports/public-content-queries.ts";
import type { StudioAssetGateway } from "./ports/studio-asset-gateway.ts";
import type { StudioGateway } from "./ports/studio-gateway.ts";
export const TECH_LOG_FEATURE_ID = "tech-log" as const;
@@ -6,6 +7,7 @@ export const TECH_LOG_FEATURE_ID = "tech-log" as const;
export type TechLogFeatureInput = Readonly<{
publicContent: PublicContentQueries;
createStudioGateway(): StudioGateway;
createStudioAssetGateway(): StudioAssetGateway;
}>;
declare module "../../../application/ports/in/application-api.ts" {
@@ -7,6 +7,7 @@ import {
} from "react";
import { isStudioGatewayError } from "../../application/ports/studio-gateway-error.ts";
import type { StudioAssetGateway } from "../../application/ports/studio-asset-gateway.ts";
import type { StudioGateway } from "../../application/ports/studio-gateway.ts";
import type { ResolvePublishedLabel } from "../../domain/public-render-content.ts";
import type {
@@ -25,6 +26,9 @@ import { useBeforeUnload } from "./components/use-before-unload.ts";
type StudioProviderProps = Readonly<{
children: ReactNode;
createGateway: () => StudioGateway;
// Optional so test harnesses that only exercise the document gateway keep
// working unchanged. `StudioShell` always supplies one in the running app.
createAssetGateway?: () => StudioAssetGateway;
resolvePublishedLabel?: ResolvePublishedLabel;
now?: () => Date;
navigate?: (href: string) => void;
@@ -48,11 +52,18 @@ const missingPublishedLabel: ResolvePublishedLabel = () => undefined;
export function StudioProvider({
children,
createGateway,
createAssetGateway,
resolvePublishedLabel = missingPublishedLabel,
now = () => new Date("2026-08-14T01:00:00.000Z"),
navigate = defaultNavigate,
}: StudioProviderProps) {
const [gateway] = useState<StudioGateway>(() => createGateway());
// Same lazy, called-once-per-mount pattern as `gateway`. Both are keyed to
// the same provider generation in `StudioShell`, so a persisted `pageshow`
// remount recreates them together — never one without the other.
const [assetGateway] = useState<StudioAssetGateway | null>(
() => createAssetGateway?.() ?? null,
);
const [editor, setEditor] = useState<StudioEditorState | null>(null);
const [pendingHref, setPendingHref] = useState<string | null>(null);
const [requestAnnouncement, setRequestAnnouncement] = useState("");
@@ -148,6 +159,7 @@ export function StudioProvider({
const value = useMemo<StudioContextValue>(
() => ({
gateway,
assetGateway,
resolvePublishedLabel,
now,
editor,
@@ -160,6 +172,7 @@ export function StudioProvider({
clearEditor,
}),
[
assetGateway,
beginEditor,
clearEditor,
editor,
@@ -47,6 +47,10 @@ export function StudioShell({ children }: StudioShellProps) {
() => application.features.get(TECH_LOG_FEATURE_ID).createStudioGateway(),
[application],
);
const createAssetGateway = useCallback(
() => application.features.get(TECH_LOG_FEATURE_ID).createStudioAssetGateway(),
[application],
);
const resolvePublishedLabel = useCallback(
(path: string) =>
application.features
@@ -69,6 +73,7 @@ export function StudioShell({ children }: StudioShellProps) {
<StudioProvider
key={generation}
createGateway={createGateway}
createAssetGateway={createAssetGateway}
resolvePublishedLabel={resolvePublishedLabel}
navigate={navigateInternal}
>
@@ -1,5 +1,6 @@
import { createContext, useContext, useMemo } from "react";
import type { StudioAssetGateway } from "../../application/ports/studio-asset-gateway.ts";
import type { StudioGateway } from "../../application/ports/studio-gateway.ts";
import type { ResolvePublishedLabel } from "../../domain/public-render-content.ts";
import type {
@@ -18,6 +19,10 @@ export type StudioEditorState = Readonly<{
export type StudioContextValue = Readonly<{
gateway: StudioGateway;
// `null` only in test harnesses that render `StudioProvider` without an
// `createAssetGateway` prop. `StudioShell` — the real app path — always
// supplies one, so production code sees this populated.
assetGateway: StudioAssetGateway | null;
resolvePublishedLabel: ResolvePublishedLabel;
now(): Date;
editor: StudioEditorState | null;